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 or npm 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>
<!-- delegate to parent -->
.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 Function unsafe?
No more than inline onclick handlers — 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.