∴ sprae

DOM microhydration. Add reactivity to the HTML via :-attributes.
- No build step, no new syntax.
- Signals-based and pluggable.
- Full-JS CSP build for strict env and browser extensions.
Use for server-rendered pages, static sites, or prototypes – anywhere a full framework is overkill, with any backend and +JSX.
<!-- Day/Night switch -->
<div id="app" :scope="{ isDark: false }">
<button :onclick="isDark = !isDark">
<span :text="isDark ? '🌙' : '☀️'"></span>
</button>
<div :class="isDark ? 'dark' : 'light'">Welcome to Spræ!</div>
</div>
<style>
.light { background: #fff; color: #000; }
.dark { background: #333; color: #fff; }
</style>
<!-- default -->
<script type="module" src="//unpkg.com/sprae"></script>With an ES module:
import sprae from 'sprae'
const state = sprae(document.querySelector('#app'), { count: 0 })
state.count++ // updates DOMSprae evaluates :-attributes and evaporates them, returning reactive state.
Set text content.
<span :text="user.name">Guest</span>
<span :text="count + ' items'"></span>
<span :text="text => text.toUpperCase()">hello</span> <!-- function form -->Set innerHTML. Initializes directives in inserted content.
<article :html="marked(content)"></article>
<!-- template form -->
<section :html="document.querySelector('#card')"></section>
<!-- function form -->
<div :html="html => DOMPurify.sanitize(html)"></div>Set classes from object, array, or string.
<div :class="{ active: isActive, disabled }"></div>
<div :class="['btn', size, variant]"></div>
<div :class="isError && 'error'"></div>
<!-- function form: extend existing -->
<div :class="cls => [...cls, 'extra']"></div>Set inline styles from object or string. Supports CSS variables.
<div :style="{ color, opacity, '--size': size + 'px' }"></div>
<div :style="'color:' + color"></div>
<!-- function form -->
<div :style="style => ({ ...style, color })"></div>Set any attribute. Spread form for multiple.
<button :disabled="loading" :aria-busy="loading">Save</button>
<input :id:name="fieldName" />
<input :="{ type: 'email', required, placeholder }" />Conditional rendering. Removes element from DOM when false.
<div :if="loading">Loading...</div>
<div :else :if="error" :text="error"></div>
<div :else>Ready!</div>
<!-- fragment -->
<template :if="showDetails">
<dt>Name</dt>
<dd :text="name"></dd>
</template>Iterate arrays, objects, numbers.
<li :each="item in items" :text="item.name"></li>
<li :each="item, index in items" :text="index + '. ' + item.name"></li>
<li :each="value, key in object" :text="key + ': ' + value"></li>
<li :each="n in 5" :text="'Item ' + n"></li>
<!-- filter (reactive) -->
<li :each="item in items.filter(i => i.active)" :text="item.name"></li>
<!-- fragment -->
<template :each="item in items">
<dt :text="item.term"></dt>
<dd :text="item.definition"></dd>
</template>Create local reactive state. Inherits from parent scope.
<div :scope="{ count: 0, open: false }">
<button :onclick="count++">Count: <span :text="count"></span></button>
</div>
<!-- inline variables -->
<span :scope="x = 1, y = 2" :text="x + y"></span>
<!-- access parent scope -->
<div :scope="{ local: parentValue * 2 }">...</div>
<!-- function form -->
<div :scope="scope => ({ double: scope.value * 2 })">...</div>Bind state to form input (state → DOM).
<input :value="query" />
<textarea :value="content"></textarea>
<input type="checkbox" :value="agreed" />
<select :value="country">
<option :each="c in countries" :value="c.code" :text="c.name"></option>
</select>Write-back from input to state (DOM → state). Handles type coercion.
<input :value="query" :change="v => query = v" />
<input type="number" :value="count" :change="v => count = v" />
<input :value="search" :change.debounce-300="v => search = v" />Run side effect. Return cleanup function for disposal.
<div :fx="console.log('count changed:', count)"></div>
<div :fx="() => {
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}"></div>Store element reference in state. Function form calls with element.
<canvas :ref="canvas" :fx="draw(canvas)"></canvas>
<input :ref="el => el.focus()" />
<!-- path reference -->
<input :ref="$refs.email" />For lifecycle hooks with setup/cleanup, use
:mount.
Attach event listeners. Chain modifiers with ..
<button :onclick="count++">Click</button>
<form :onsubmit.prevent="handleSubmit()">...</form>
<input :onkeydown.enter="send()" />
<input :oninput:onchange="e => validate(e)" />
<!-- sequence: setup on first event, cleanup on second -->
<div :onfocus..onblur="e => (active = true, () => active = false)"></div>:hidden
Toggle hidden attribute. Unlike :if, keeps element in DOM.
<p :hidden="!ready">Loading...</p>Run a non-reactive lifecycle hook once when the element connects. The hook can return a cleanup function.
<canvas :mount="el => initChart(el)"></canvas>
<div :mount="el => {
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
}"></div>Run an expression when the element enters the viewport. A function receives the observer entry.
<img :intersect.once="loadImage()" :src="placeholder" />
<div :intersect="entry => visible = entry.isIntersecting"></div>ResizeObserver wrapper.
<div :resize="({width}) => cols = Math.floor(width / 200)"></div>Move element to another container.
<div :portal="'#modals'">Modal content</div>
<dialog :portal="open && '#portal-target'">...</dialog>Chain with . after directive name.
<input :oninput.debounce-300="search()" /> <!-- delay until activity stops -->
<div :onscroll.throttle-100="update()">...</div> <!-- limit frequency -->
<div :onmouseenter.delay-500="show = true" /> <!-- delay each call -->
<button :onclick.once="init()">Initialize</button>Time formats: 100 (ms), 100ms, 1s, 1m, raf, idle, tick.
Add -immediate to debounce for leading edge.
<div :onkeydown.window.escape="close()">...</div>
<div :onclick.self="only direct clicks"></div>
<div :onclick.away="open = false">Click outside to close</div>.window .document .body .root .parent .self .away
<a :onclick.prevent="navigate()" href="/fallback">Link</a>
<button :onclick.stop="handleClick()">Don't bubble</button>.prevent .stop .stop-immediate .passive .capture
Filter keyboard events by key or combination.
.ctrl,.shift,.alt,.meta: modifier keys.enter,.esc,.tab,.space: common keys.delete: delete or backspace.arrow: any arrow key.digit: 0-9.letter: any Unicode letter.char: any non-space character.ctrl-<key>,.alt-<key>,.meta-<key>,.shift-<key>: combinations
<input :onkeydown.enter="submit()" />
<input :onkeydown.ctrl-s.prevent="save()" />
<input :onkeydown.shift-enter="newLine()" />
<input :onkeydown.meta-x="cut()" />Sprae uses signals for reactivity.
import { signal, computed, effect, batch } from 'sprae'
const count = signal(0)
const doubled = computed(() => count.value * 2)
effect(() => console.log('Count:', count.value))
count.value++store() creates reactive objects from plain data. Getters become computed values. Properties prefixed with _ are untracked.
import sprae, { store } from 'sprae'
const state = store({
count: 0,
items: [],
increment() { this.count++ },
get double() { return this.count * 2 },
_cache: {} // untracked
})
sprae(element, state)
state.count++ // reactive
state._cache.x = 1 // not reactiveReplace the built-in signals with any Preact Signals-compatible library:
<script src="//unpkg.com/sprae/dist/sprae-preact.umd.js" data-start></script>import sprae from 'sprae'
import * as signals from '@preact/signals-core'
sprae.use(signals)| Library | Size | Notes |
|---|---|---|
| Built-in | ~300b | Default |
| @preact/signals-core | 1.5kb | Compatibility target |
| ulive | 350b | Smallest |
| signal | 633b | Minimal |
| usignal | 955b | Async effects |
import sprae, { directive, parse, modifier } from 'sprae'
import jessie from 'subscript/jessie'
sprae.use({
// CSP-safe evaluator: <script src="//unpkg.com/sprae/dist/sprae-csp.umd.js" data-start></script>
// or define manually
compile: jessie,
// custom prefix: <div data-text="message">...</div>
prefix: 'data-'
})
// Custom directive
directive.id = (el, state, expr) => value => el.id = value
directive.timer = (el, state, expr) => {
let id
return ms => {
clearInterval(id)
id = setInterval(() => el.textContent = Date.now(), ms)
return () => clearInterval(id)
}
}
// Custom modifier
modifier.log = (fn) => (e) => (console.log(e.type), fn(e))Keep server components and let sprae handle client-side interactivity without 'use client':
// layout.jsx
import Script from 'next/script'
export default function Layout({ children }) {
return <>
{children}
<Script src="https://unpkg.com/sprae" data-prefix="x-" data-start />
</>
}// page.jsx: server component without 'use client'
export default function Page() {
return <div x-scope="{count: 0}">
<button x-onclick="count++">
Clicked <span x-text="count">0</span> times
</button>
</div>
}Markdown processors strip : attributes, so use data- prefix:
<script src="https://unpkg.com/sprae" data-prefix="data-" data-start></script><div data-scope="{ count: 0 }">
<button data-onclick="count++">
Clicked <span data-text="count">0</span> times
</button>
</div>Sprae works with Jekyll, Hugo, Eleventy, and Astro. Its own site uses this setup.
PHP, Django, Rails, and Jinja can render the HTML while sprae handles client-side interactivity:
<script src="https://unpkg.com/sprae" data-start></script>
<div :scope="{ count: <?= $initial ?> }">
<button :onclick="count++">Count: <span :text="count"></span></button>
</div>Sprae treats a custom element as a boundary. Directives set its props, but sprae does not descend into its children. The component owns its DOM.
<user-card :each="u in users" :name="u.name" :avatar="u.avatar"></user-card>Works with define-element, Lit, or any CE library.
- Prevent FOUC:
<style>[\:each],[\:if],[\:else]{visibility:hidden}</style> - Attribute order matters:
:eachbefore:text, not after. - Async expressions work:
<div :text="await fetchData()"></div> - Dispose:
sprae.dispose(el)orel[Symbol.dispose]() :eachkeys object items by identity and primitives by position; nokeyis needed.thisrefers to current element, but prefer:refor:mountfor element access.- Properties prefixed with
_are untracked.
How does it compare to Alpine?
Sprae is ~1.5× smaller over the wire, ~2.3× faster, and uses ~3× less runtime memory in this comparison. It has pluggable signals, built-in modifiers, event chains, and a full-JS CSP build.
How does it compare to React/Vue?
Sprae needs no build step or virtual DOM. In JSX, it adds client-side interactivity without 'use client'.
Why signals?
Signals have a TC39 proposal, and sprae accepts any Preact Signals-compatible implementation.
Is new Function unsafe?
new Function executes directive expressions as JavaScript. Use the default build only with trusted markup; under strict CSP, use the CSP build.
Components?
Use define-element for declarative web components, or any custom-element library. For simpler cases, manage duplication with templates or includes.
TypeScript?
Full types included.
Browser support?
Any browser with Proxy (all modern browsers, no IE).
Does it scale?
State uses plain reactive objects. For complex apps, use store with computed getters and methods.
Is it production-ready?
Sprae has 3+ years of releases across ~200 npm versions. It has no dependencies or open issues, and its tests include the CSP build.
Is it backed by a company?
Indie project. Support it.
settings-panel, wavearea, watr