∴ spræ
DOM microhydration
Reactive :attributes for your HTML/JSX — interactivity without a framework.
Principles
- HTML-native
- Keep existing HTML.
Standard JS expressions.
No build step, no config. - ~8kb, 0 deps
- One
<script>tag ornpm i.
Any backend, any template, +JSX.
No ecosystem lock-in. - Open & pluggable
- Direct state access.
Swappable signals, custom directives.
No eval — CSP-safe sandbox.
Usage
Add one script tag. Sprae evaluates : attributes and makes reactivity.
<script src="//unpkg.com/sprae" data-start></script>
Variants:
<!-- CSP-safe (no eval) -->
<script src="//unpkg.com/sprae/dist/sprae-csp.umd.js" data-start></script>
<!-- Preact signals -->
<script src="//unpkg.com/sprae/dist/sprae-preact.umd.js" data-start></script>
Install or download sprae.js and import:
<script type="module">
import sprae from './sprae.js'
const state = sprae(document.getElementById('app'), { count: 0 })
state.count++ // updates DOM
</script>
Variants: sprae-csp.js (CSP-safe), sprae-preact.js (preact signals).
Keep server components — sprae handles client interactivity, no '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, no 'use client' needed
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 — use the 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>
Works with Jekyll, Hugo, Eleventy, Astro — and server templates: PHP, Django, Rails, Jinja. This site is built this way.
Reference
:textSet text content<span :text="name">
<span :text="user.name">Guest</span> <span :text="count + ' items'"></span> <!-- function form --> <span :text="text => text.toUpperCase()">hello</span>
:htmlSet innerHTML<div :html="content">
<article :html="marked(content)"></article>
<!-- template element -->
<section :html="document.querySelector('#card')"></section>
<!-- function form -->
<div :html="html => DOMPurify.sanitize(html)"></div>:classSet classes<div :class="{active: true}">
<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>:styleSet styles<div :style="{color:'#fff'}">
<div :style="{ color, opacity, '--size': size + 'px' }"></div>
<div :style="'color:' + color"></div>
<!-- function form -->
<div :style="style => ({ ...style, color })"></div>:valueBind input (state→DOM)<input :value="text">
<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>
:changeWrite input back (DOM→state)<input :change="v => text = v">
<input :value="query" :change="v => query = v" /> <!-- coerces type --> <input type="number" :value="count" :change="v => count = v" /> <!-- debounced write --> <input :value="search" :change.debounce-300="v => search = v" />
:<prop>Set any attribute<a :href="url">
<button :disabled="loading" :aria-busy="loading">Save</button>
<!-- multiple attrs at once -->
<input :id:name="fieldName" />
<!-- spread form -->
<input :="{ type: 'email', required, placeholder }" />:hiddenToggle visibility<div :hidden="!show">
<!-- unlike :if, keeps the element in DOM --> <p :hidden="!ready">Loading...</p>
:if :elseConditional render<div :if="cond">
<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>
:eachList render<li :each="item in list">
<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> <!-- reactive filter --> <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>
:scopeCreate local state<div :scope="{x:1}">
<div :scope="{ count: 0, open: false }">...</div>
<!-- inline variables -->
<span :scope="x = 1, y = 2" :text="x + y"></span>
<div :scope="{ local: parentValue * 2 }">...</div>
<!-- function form -->
<div :scope="scope => ({ double: scope.value * 2 })">...</div>:refElement reference<input :ref="name">
<canvas :ref="canvas" :fx="draw(canvas)"></canvas> <!-- function form --> <input :ref="el => el.focus()" /> <!-- path reference --> <input :ref="$refs.email" />
:mountConnect/cleanup hook<canvas :mount="el => init(el)">
<canvas :mount="el => initChart(el)"></canvas>
<!-- return cleanup, runs on disconnect -->
<div :mount="el => {
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
}"></div>:intersectVisibility observer<img :intersect.once="load()">
<img :intersect.once="loadImage()" :src="placeholder" /> <!-- full control --> <div :intersect="entry => visible = entry.isIntersecting"></div>
:resizeSize observer<div :resize="({width}) => ...">
<div :resize="({width}) => cols = Math.floor(width / 200)"></div>:fxSide effect<div :fx="log(x)">
<div :fx="console.log('count changed:', count)"></div>
<!-- return cleanup -->
<div :fx="() => {
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}"></div>:on<event>Event listener<button :onclick="fn()">
<form :onsubmit.prevent="handleSubmit()">...</form> <input :onkeydown.enter="send()" /> <!-- multiple events --> <input :oninput:onchange="e => validate(e)" /> <!-- setup..cleanup sequence --> <div :onfocus..onblur="e => (active = true, () => active = false)"></div>
:portalMove to container<div :portal="'#modals'">
<div :portal="'#modals'">Modal content</div> <!-- conditional target --> <dialog :portal="open && '#portal-target'">...</dialog>
.debounceDelay until activity stops:oninput.debounce-300
<input :oninput.debounce-300="search()" /> <!-- leading edge --> <input :oninput.debounce-1s-immediate="save()" /> <!-- formats: 100, 100ms, 1s, 1m, raf, idle, tick -->
.throttleLimit call frequency:onscroll.throttle-100
<div :onscroll.throttle-100="update()">...</div> <!-- once per frame --> <div :onmousemove.throttle-raf="track()">...</div>
.delayDelay each call:onmouseenter.delay-500
<div :onmouseenter.delay-500="show = true">...</div>
.onceRun only once:onclick.once
<button :onclick.once="init()">Initialize</button> <!-- works on any directive --> <div :fx.once="fetchData()"></div>
.windowListen on window:onkeydown.window.escape
<div :onkeydown.window.escape="close()">...</div> <div :onresize.window="w = innerWidth"></div>
.documentListen on document:onclick.document
<div :onselectionchange.document="onSelect()"></div>
.body .root .parentOther targets:onclick.parent
<li :onclick.parent="select()">...</li>
.selfOnly direct target:onclick.self
<div :onclick.self="close()">ignores clicks on children</div>
.awayClick outside element:onclick.away
<menu :onclick.away="open = false">click outside to close</menu>
.preventPrevent default:onclick.prevent
<form :onsubmit.prevent="save()">...</form> <a :onclick.prevent="navigate()" href="/fallback">Link</a>
.stopStop propagation:onclick.stop
<button :onclick.stop="handleClick()">Don't bubble</button> <button :onclick.stop-immediate="only()">...</button>
.passive .captureListener options:onscroll.passive
<div :onscroll.passive="onScroll()">...</div> <div :onclick.capture="first()">...</div>
.enter .esc .tab .spaceCommon keys:onkeydown.enter
<input :onkeydown.enter="submit()" /> <!-- also: .delete, .arrow, .digit, .letter, .char -->
.ctrl .shift .alt .metaModifier keys, combos:onkeydown.ctrl-s
<input :onkeydown.ctrl-s.prevent="save()" /> <input :onkeydown.shift-enter="newLine()" /> <input :onkeydown.meta-x="cut()" />
.arrow .digit .letter .deleteKey groups:onkeydown.digit
<input :onkeydown.arrow="e => navigate(e.key)" /> <input :onkeydown.digit="e => enterPin(e.key)" />
FAQ
- How does it compare?
- ~2× lighter and ~2× faster than Alpine — measured. Actively maintained, unlike petite-vue. Signals-powered (emerging standard). Migrating? Alpine → sprae guide.
- Strict CSP? Browser extension?
- Yes — the CSP build runs full JS expressions with no
eval/new Function, where Alpine’s CSP build forbids even arrow functions. Works in Chrome MV3 extensions. - Components?
- Use define-element for declarative web components, or any CE library.
- Is
new Functionunsafe? - No more than inline
onclickhandlers — expressions are sandboxed to state scope. For no-eval environments there’s the CSP build. - Does it scale?
- State is plain reactive objects — scales as far as your data model does. Use store with computed getters and methods for complex apps.
- Browser support?
- Any browser with Proxy — all modern browsers, no IE.
- Is it production-ready?
- 3+ years · 20+ releases · 0 open issues · 0 dependencies · full TypeScript types · test suite.