Skip to content

dy/sprae

Repository files navigation

sprae tests size npm

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

CSP-safe evaluator

Default uses new Function (fast, but requires unsafe-eval CSP).
For strict CSP, use the pre-built variant or wire jessie manually:

<script src="//unpkg.com/sprae/dist/sprae-csp.umd.js" data-start></script>
import sprae from 'sprae'
import jessie from 'subscript/jessie'
sprae.use({ compile: jessie })

Custom prefix

sprae.use({ prefix: 'data-' })
<div data-text="message">...</div>

Custom directive

import { directive, parse } from 'sprae'

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

import { modifier } from 'sprae'
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}
    <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 / 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>

Works with Jekyll, Hugo, Eleventy, Astro. Sprae site itself is built this way.

Server Templates

Same pattern works with PHP, Django, Rails, Jinja — server renders HTML, sprae handles client 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 custom elements as boundaries — directives on the element set props, but sprae does not descend into 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: :each before :text, not after.
  • Async expressions work: <div :text="await fetchData()"></div>
  • Dispose: sprae.dispose(el) or el[Symbol.dispose]()
  • No key needed — :each uses direct list mapping, not DOM diffing.
  • this refers to current element, but prefer :ref or :mount for element access.
  • Properties prefixed with _ are untracked.

FAQ

What is sprae? : ~5kb script that adds reactivity to HTML via :attribute="expression". No build step, no new syntax.

Learning curve? : If you know HTML and JS, you know sprae. Just :attribute="expression".

How does it compare to Alpine? : 3x lighter, pluggable signals, prop modifiers, event chains. Faster in benchmarks.

How does it compare to React/Vue? : No build step, no virtual DOM. Can inject into JSX for server components without framework overhead.

Why signals? : Signals are the emerging standard for reactivity. Pluggable — first to support native signals when browsers ship.

Is new Function unsafe? : No more than inline onclick handlers. For strict CSP, use the safe evaluator.

Components? : Use define-element for declarative web components, or any CE library. For simpler cases, manage duplication with templates/includes.

TypeScript? : Full types included.

Browser support? : Any browser with Proxy (all modern browsers, no IE).

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.

Is it production-ready? : It is used by a few SaaS systems and landing pages of big guys.

Is it backed by a company? : Indie project. Support it.

Used by

settings-panel · wavearea · watr

Refs

alpine · petite-vue · lucia · nuejs · hmpl · unpoly · dagger