Simple progressive enhancement for DOM or JSX.
<div id="counter" :scope="{ count: 0 }">
<p :text="`Clicked ${count} times`"></p>
<button :onclick="count++">Click me</button>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]" start></script>Sprae evaluates :-directives enabling reactivity.
Set text content.
Welcome, <span :text="user.name">Guest</span>.
<!-- fragment -->
Welcome, <template :text="user.name"><template>.
<!-- function -->
<span :text="val => val + text"></span>Set className.
<div :class="foo"></div>
<!-- appends to static class -->
<div class="bar" :class="baz"></div>
<!-- array/object, a-la clsx -->
<div :class="['foo', bar && 'bar', { baz }]"></div>
<!-- function -->
<div :class="str => [str, 'active']"></div>Set style.
<span :style="'display: inline-block'"></span>
<!-- extends static style -->
<div style="foo: bar" :style="'bar-baz: qux'">
<!-- object -->
<div :style="{bar: 'baz', '--qux': 'quv'}"></div>
<!-- function -->
<div :style="obj => ({'--bar': baz})"></div>Bind input, textarea or select value.
<input :value="value" />
<textarea :value="value" />
<!-- handles option & selected attr -->
<select :value="selected">
<option :each="i in 5" :value="i" :text="i"></option>
</select>
<!-- checked attr -->
<input type="checkbox" :value="item.done" />
<!-- function -->
<input :value="value => value + str" />Set any attribute(s).
<label :for="name" :text="name" />
<!-- multiple -->
<input :id:name="name" />
<!-- function -->
<div :hidden="hidden => !hidden"></div>
<!-- spread -->
<input :="{ id: name, name, type: 'text', value, ...props }" />Control flow.
<span :if="foo">foo</span>
<span :else :if="bar">bar</span>
<span :else>baz</span>
<!-- fragment -->
<template :if="foo">foo <span>bar</span> baz</template>
<!-- function -->
<span :if="active => test()"></span>Multiply content.
<ul><li :each="item in items" :text="item" /></ul>
<!-- cases -->
<li :each="item, idx? in array" />
<li :each="value, key? in object" />
<li :each="count, idx? in number" />
<li :each="item, idx? in function" />
<!-- fragment -->
<template :each="item in items">
<dt :text="item.term"/>
<dd :text="item.definition"/>
</template>Define state container for a subtree.
<!-- transparent -->
<x :scope="{foo: 'foo'}">
<y :scope="{bar: 'bar'}" :text="foo + bar"></y>
</x>
<!-- define variables -->
<x :scope="x=1, y=2" :text="x+y"></x>
<!-- blank -->
<x :scope :ref="id"></x>
<!-- access to local scope instance -->
<x :scope="scope => { scope.x = 'foo'; return scope }" :text="x"></x>Run effect.
<!-- inline -->
<div :fx="a.value ? foo() : bar()" />
<!-- function / cleanup -->
<div :fx="() => (id = setInterval(tick, 1000), () => clearInterval(id))" />Expose an element in scope or get ref to the element.
<div :ref="card" :fx="handle(card)"></div>
<!-- reference -->
<div :ref="el => el.innerHTML = '...'"></div>
<!-- local reference -->
<li :each="item in items" :scope :ref="li">
<input :onfocus="e => li.classList.add('editing')"/>
</li>
<!-- mount / unmount -->
<textarea :ref="el => {/* onmount */ return () => {/* onunmount */}}" :if="show"></textarea>Add event listener.
<!-- inline -->
<button :onclick="count++">Up</button>
<!-- function -->
<input type="checkbox" :onchange="event => isChecked = event.target.value">
<!-- multiple -->
<input :onvalue="text" :oninput:onchange="event => text = event.target.value">
<!-- sequence -->
<button :onfocus..onblur="evt => { handleFocus(); return evt => handleBlur()}">
<!-- modifiers -->
<button :onclick.throttle-500="handle()">Not too often</button>Defer callback by ms, next tick/animation frame, or until idle. Defaults to 250ms.
<!-- debounce keyboard input by 200ms -->
<input :oninput.debounce-200="event => update(event)" />
<!-- set class in the next tick -->
<div :class.debounce-tick="{ active }">...</div>
<!-- debounce resize to animation framerate -->
<div :onresize.window.debounce-frame="updateSize()">...</div>
<!-- batch logging when idle -->
<div :fx.debounce-idle="sendAnalytics(batch)"></div>Limit callback rate to interval in ms, tick or animation framerate. By default 250ms.
<!-- throttle text update -->
<div :text.throttle-100="text.length"></div>
<!-- lock style update to animation framerate -->
<div :onscroll.throttle-frame="progress = (scrollTop / scrollHeight) * 100"/>
<!-- ensure separate stack for events -->
<div :onmessage.window.throttle-tick="event => log(event)">...</div>Call only once.
<!-- run event callback only once -->
<button :onclick.once="loadMoreData()">Start</button>
<!-- run once on sprae init -->
<div :fx.once="console.log('sprae init')">Specify event target.
<!-- close dropdown when click outside -->
<div :onclick.outside="closeMenu()" :class="{ open: isOpen }">Dropdown</div>
<!-- interframe communication -->
<div :onmessage.window="e => e.data.type === 'success' && complete()">...</div>Event listener options.
<div :onscroll.passive="e => pos = e.scrollTop">Scroll me</div>
<body :ontouchstart.capture="logTouch(e)"></body>Prevent default or stop (immediate) propagation.
<!-- prevent default -->
<a :onclick.prevent="navigate('/page')" href="/default">Go</a>
<!-- stop immediate propagation -->
<button :onclick.stop-immediate="criticalHandle()">Click</button>Filter event by event.key or combination:
.ctrl,.shift,.alt,.meta,.enter,.esc,.tab,.space– direct key.delete– delete or backspace.arrow– up, right, down or left arrow.digit– 0-9.letter– A-Z, a-z or any unicode letter.char– any non-space character
<!-- any arrow event -->
<div :onkeydown.arrow="event => navigate(event.key)"></div>
<!-- key combination -->
<input :onkeydown.prevent.ctrl-c="copy(clean(value))">Any other modifier has no effect, but allows binding multiple handlers.
<span :fx.once="init(x)" :fx.update="() => (update(), () => destroy())">Sprae uses signals store for reactivity.
import sprae, { store, signal, effect, computed } from 'sprae'
const name = signal('foo');
const capname = computed(() => name.value.toUpperCase());
const state = store(
{
count: 0, // prop
inc(){ this.count++ }, // method
name, capname, // signal
get twice(){ return this.count * 2 }, // computed
_i: 0, // untracked
},
// globals / sandbox
{ Math }
)
// manual init
sprae(element, state)
state.inc(), state.count++ // update
name.value = 'bar' // signal update
state._i++ // no update
state.Math // == globalThis.Math
state.navigator // == undefinedDefault signals can be replaced with preact-signals alternative:
import sprae from 'sprae';
import { signal, computed, effect, batch, untracked } from 'sprae/signal';
import * as signals from '@preact/signals-core';
sprae.use(signals);| Provider | Size | Feature |
|---|---|---|
ulive |
350b | Minimal implementation, basic performance, good for small states. |
signal |
633b | Class-based, better performance, good for small-medium states. |
usignal |
955b | Class-based with optimizations and optional async effects. |
@preact/signals-core |
1.47kb | Best performance, good for any states, industry standard. |
signal-polyfill |
2.5kb | Proposal signals. Use via adapter. |
alien-signals |
2.67kb | Preact-flavored alien signals. |
Default evaluator is fast and compact, but violates "unsafe-eval" CSP.
To make eval stricter & safer, any alternative can be used, eg. justin:
import sprae from 'sprae'
import justin from 'subscript/justin'
sprae.use({compile: justin})The start or data-sprae-start attribute automatically starts sprae on document. It can use a selector to adjust target container.
<div id="counter" :scope="{count: 1}">
<p :text="`Clicked ${count} times`"></p>
<button :onclick="count++">Click me</button>
</div>
<script src="./sprae.js" data-sprae-start="#counter"></script>For manual start, remove start attribute:
<script src="./sprae.js"></script>
<script>
// watch & autoinit els
sprae.start(document.body, { count: 1 });
// OR init individual el (no watch)
const state = sprae(document.getElementById('counter'), { count: 0 })
</script>For more control use ESM:
<script type="module">
import sprae from './sprae.js'
// init
const state = sprae(document.getElementById('counter'), { count: 0 })
// update state
state.count++
</script>Sprae works with JSX via custom prefix (eg. data-sprae-).
Useful to offload UI logic from server components in react / nextjs, instead of converting them to client components.
// app/page.jsx - server component
export default function Page() {
return <>
<nav id="nav">
<a href="/" data-sprae-class="location.pathname === '/' && 'active'">Home</a>
<a href="/about" data-sprae-class="location.pathname === '/about' && 'active'">About</a>
</nav>
...
</>
}// layout.jsx
import Script from 'next/script'
export default function Layout({ children }) {
return <>
{children}
<Script src="https://unpkg.com/sprae" data-sprae-prefix="data-sprae-" data-sprae-start />
</>
}Sprae build can be tweaked for project needs / size:
// sprae.custom.js
import sprae, { directive, use } from 'sprae/core'
import * as signals from '@preact/signals'
import compile from 'subscript/justin'
import _default from 'sprae/directive/default.js'
import _if from 'sprae/directive/if.js'
import _text from 'sprae/directive/text.js'
use({
// custom prefix, defaults to ':'
prefix: 'data-sprae-',
// use preact signals
...signals,
// use safer compiler
compile
})
// standard directives
directive.if = _if;
directive.text = _text;
directive.default = _default;
// custom directive :id="expression"
directive.id = (el, state, expr) => {
// ...init
return newValue => {
// ...update
let nextValue = el.id = newValue
return nextValue
}
}
export default sprae;- To prevent FOUC add
<style>[\:each],[\:if],[\:else] {visibility: hidden}</style>. - Attributes order matters, eg.
<li :each="el in els" :text="el.name"></li>is not the same as<li :text="el.name" :each="el in els"></li>. - Invalid self-closing tags like
<a :text="item" />cause error. Valid self-closing tags are:li,p,dt,dd,option,tr,td,th,input,img,br. - To destroy state and detach sprae handlers, call
element[Symbol.dispose](). thisis not used, to get element reference use:ref="element => {...}".keyis not used,:eachuses direct list mapping instead of DOM diffing.- Expressions can be async:
<div :text="await load()"></div>
Modern frontend is unhealthy—like processed, non-organic food. Frameworks force you into JS-land: build pipelines for "Hello World", proprietary conventions, virtual DOM overhead, brittle tooling. Pages are not functional without JS. Progressive enhancement is anachronism. Build tools should be optional, not mandatory. Frameworks should enhance HTML, not replace it.
Native template-parts and DCE give hope, but quite distant and stuck with HTML quirks 1, 2, 3.
Alpine and petite-vue offer progressive enhancement, but introduce invalid syntax @click, bloated API, opaque reactivity, self-encapsulation, limited extensibility, size / performance afterthoughts.
Sprae holds open, safe, minimalistic philosophy:
- One
:prefix. Valid HTML. Zero magic. - Signals reactivity. (preact-signals compatible)
- Plugggable: signals, eval, directives, modifiers.
- Build-free, ecosystem-agnostic:
<script src>, JSX, anything. - Small, safe & fast.
- 🫰 developers
- ToDo MVC: demo, code
- JS Framework Benchmark: demo, code
- Wavearea: demo, code
- Carousel: demo, code
- Tabs: demo, code