Sprae
DOM tree microhydration
Install / Use
/learn @dy/SpraeREADME
∴ sprae

Microhydration for HTML/JSX tree.
Open & minimal PE framework with signals-based reactivity.
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>
Or with module:
import sprae from 'sprae'
const state = sprae(document.querySelector('#app'), { count: 0 })
state.count++ // updates DOM
Sprae 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>
: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
Lifecycle hook — runs once on connect. Not reactive. Can return cleanup.
<canvas :mount="el => initChart(el)"></canvas>
<div :mount="el => {
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
}"></div>
:intersect
IntersectionObserver wrapper. Fires on enter, or receive entry for full control.
<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, _-prefixed properties 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 reactive
Alternative signals
Replace 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 | Industry standard, best performance | | ulive | 350b | Minimal | | signal | 633b | Enhanced performance. | | usignal | 955b | Async effects support |
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
Avoids 'use client' — keep server components, let sprae handle client-side interactivity:
// layout.jsx
import Script from 'next/script'
export default function Layout({ children }) {
return <>
{children}
<Scrip
