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/sprae@12.x.x/dist/sprae.umd.js" start></script>
Sprae enables reactivity via :
-directives.
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 }
)
sprae(element, state). // init
state.inc(), state.count++ // update
name.value = 'bar' // signal update
state._i++ // no update
state.Math // == globalThis.Math
state.navigator // == undefined
Default 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
/ data-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-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:
DFB<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. s-
).
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="/" s-class="location.pathname === '/' && 'active'">Home</a>
<a href="/about" s-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-prefix="s-" data-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]()
. this
is not used, to get element reference use:ref="element => {...}"
.key
is not used,:each
uses direct list mapping instead of DOM diffing.- Expressions can be async:
<div :text="await load()"></div>
Modern frontend is like processed food. Frameworks come with endless tooling, tedious setups and configs, proprietary conventions, artificial abstractions and ecosystem lock-in. Progressive enhancement / graceful degradation is anachronism.
Native template-parts and DCE give distant hope, but stuck with HTML quirks 1, 2, 3.
Alpine and petite-vue offer PE / GD, but have API inconsistencies (x-, @, $, etc.), tend to self-encapsulate, limit extensibility and disregard performance / size.
Sprae holds open, safe, minimalistic philosophy:
- One
:
prefix. Zero magic. - Valid HTML. Non-obtrusive.
- Signals for reactivity. (preact-signals compatible)
- Configurable signals, evaluator, directives, modifiers.
- Build-free, ecosystem-agnostic.
- Small, safe & fast.
- 🫰 developers
Ideal for small websites, static pages, prototypes, landings, SPA, PWA, JSX / SSR, micro-frontends or anywhere where you need lightweight UI.
- ToDo MVC: demo, code
- JS Framework Benchmark: demo, code
- Wavearea: demo, code
- Carousel: demo, code
- Tabs: demo, code