SkillAgentSearch skills...

Sprae

DOM tree microhydration

Install / Use

/learn @dy/Sprae

README

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

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
View on GitHub
GitHub Stars180
CategoryDevelopment
Updated2d ago
Forks8

Languages

JavaScript

Security Score

100/100

Audited on Mar 31, 2026

No findings