sprae
v13.9.5
Published
Reactive HTML attributes. No build step, signals-based, CSP-safe.
Readme
∴ 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. A lightweight Alpine.js alternative.
Usage
<!-- 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.
Directives
:text
Set text content.
<span :text="user.name">Guest</span>
<span :text="count + ' items'"></span>
<span :text="text => text.toUpperCase()">hello</span> <!-- function form -->:html
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>:class
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>:style
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>:<attr>, :="{ ...attrs }"
Set any attribute. Spread form for multiple.
<button :disabled="loading" :aria-busy="loading">Save</button>
<input :id:name="fieldName" />
<input :="{ type: 'email', required, placeholder }" />:if / :else
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>:each
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>:scope
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>
<!-- reusable: a function by name is called with the new scope -->
<div :scope="counter">...</div>:value
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>:change
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" />:fx
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>:ref
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.
:on<event>
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>:mount
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>:intersect
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>:resize
ResizeObserver wrapper.
<div :resize="({width}) => cols = Math.floor(width / 200)"></div>:portal
Move element to another container.
<div :portal="'#modals'">Modal content</div>
<dialog :portal="open && '#portal-target'">...</dialog>Modifiers
Chain with . after directive name.
Timing
<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.
Event targets
<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
Event control
<a :onclick.prevent="navigate()" href="/fallback">Link</a>
<button :onclick.stop="handleClick()">Don't bubble</button>.prevent .stop .stop-immediate .passive .capture
Key filters
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()" />Signals
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
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 reactiveAlternative signals
Replace 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 |
Configuration
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))Integration
JSX / Next.js
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 / Static Sites
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.
Server Templates
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>Web Components
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.
Hints
- 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.
FAQ
How does it compare to Alpine? Sprae is ~1.7× smaller, ~2.8× faster, and uses ~4.5× 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?
In the official js-framework-benchmark (Chrome 150), sprae places ahead of Vue, Preact and React in CPU speed, at 11.6kb compressed, with 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?
Reusable state is a plain function: <div :scope="dropdown">, no registry. For simpler cases, manage duplication with templates or includes. Otherwise use custom elements, via define-element or any CE library: sprae sets their props and stops at their boundary. Coming from Alpine.data()? See the migration guide.
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.
Used by
settings-panel, wavearea, watr
