Getting Started

Include sprae and add directives to your HTML:

<script src="https://unpkg.com/sprae" data-start></script>

<ul :scope="{ items: ['Buy milk', 'Walk dog', 'Call mom'] }">
  <li :each="item in items" :text="item"></li>
</ul>

Or initialize manually for more control:

<div id="app">
  <input :value="search" placeholder="Search..." />
  <ul>
    <li :each="item in items.filter(i => i.includes(search))" :text="item"></li>
  </ul>
</div>

<script type="module">
  import sprae from 'sprae'

  const state = sprae(document.getElementById('app'), {
    search: '',
    items: ['Apple', 'Banana', 'Cherry', 'Date']
  })

  // Update state anytime
  state.items.push('Elderberry')
</script>

CDN

<!-- Auto-init with data-start -->
<script src="https://unpkg.com/sprae" data-start></script>

<!-- Or manual init -->
<script type="module">
  import sprae from 'https://unpkg.com/sprae?module'
</script>

Package

npm i sprae
import sprae from 'sprae'

Directives

:text

Set text content of an element.

<span :text="user.name">Guest</span>
<span :text="count + ' items'"></span>

<!-- Function form: transform existing text -->
<span :text="text => text.toUpperCase()">hello</span>

:html

Set innerHTML (initializes directives in inserted content).

<article :html="marked(content)"></article>

<!-- 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 -->
<div :class="cls => [...cls, 'extra']"></div>

:style

Set inline styles from object or string.

<div :style="{ color, opacity, '--size': size + 'px' }"></div>
<div :style="'color:' + color"></div>

<!-- Function form -->
<div :style="style => ({ ...style, color })"></div>

:<attr> or :="{ ...attrs }"

Set any attribute. Use spread form for multiple.

<button :disabled="loading" :aria-busy="loading">Save</button>
<input :id:name="fieldName" />

<!-- Spread multiple attributes -->
<input :="{ type: 'email', required, placeholder }" />

:hidden

Toggle hidden attribute (element stays in DOM, unlike :if).

<div :hidden="!ready">Loading...</div>
<dialog :hidden="!open">Modal content</dialog>

:if / :else

Conditional rendering. Elements are removed from DOM when false.

<div :if="loading">Loading...</div>
<div :else :if="error" :text="error"></div>
<div :else>Ready!</div>

<!-- Multiple elements with template -->
<template :if="showDetails">
  <dt>Name</dt>
  <dd :text="name"></dd>
</template>

:each

Iterate over arrays, objects, numbers, or live functions.

<!-- Array -->
<li :each="item in items" :text="item.name"></li>
<li :each="item, index in items" :text="index + '. ' + item.name"></li>

<!-- Object -->
<li :each="value, key in user" :text="key + ': ' + value"></li>

<!-- Range -->
<li :each="n in 5" :text="'Item ' + n"></li>

<!-- Filter (reactive) -->
<li :each="item in items.filter(i => i.active)" :text="item.name"></li>

<!-- Multiple elements with template -->
<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>

:ref

Get element reference. In :each, creates local reference for each node.

<canvas :ref="canvas" :fx="draw(canvas)"></canvas>

<!-- Function form -->
<input :ref="el => el.focus()" />

<!-- With :each, local reference per iteration -->
<li :each="item in items" :ref="el">
  <!-- el is the current <li> in this iteration's scope -->
</li>

Lifecycle — return a cleanup function:

<div :ref="el => {
  const observer = new IntersectionObserver(callback)
  observer.observe(el)
  return () => observer.disconnect()
}"></div>

<!-- Shorthand -->
<div :ref="el => (setup(el), () => cleanup(el))"></div>

:fx

Run side effects. Return cleanup function for disposal.

<div :fx="console.log('count changed:', count)"></div>

<!-- With cleanup -->
<div :fx="() => {
  const id = setInterval(tick, 1000)
  return () => clearInterval(id)
}"></div>

:on<event>

Attach event listeners. Chain modifiers with ..

<button :onclick="count++">Click</button>
<form :onsubmit.prevent="handleSubmit()">...</form>
<input :onkeydown.enter="send()" />

<!-- Multiple events -->
<input :oninput:onchange="e => validate(e)" />

<!-- Sequence: setup on first event, cleanup on second -->
<div :onfocus..onblur="e => (active = true, () => active = false)"></div>

:value

Two-way bind form inputs.

<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>

<!-- One-way with formatting -->
<input :value="v => '$' + v.toFixed(2)" />

:portal

Move element to another container.

<div :portal="'#modals'">Modal content</div>
<div :portal="document.body">Toast notification</div>

<!-- Conditional: move when true, return when false -->
<dialog :portal="open && '#portal-target'">...</dialog>

Modifiers

Modifiers transform directive behavior. Chain with . after directive name.

.debounce

Delay until activity stops. Accepts time value.

<input :oninput.debounce="search()" />
<input :oninput.debounce-300="search()" />
<input :oninput.debounce-1s="save()" />

Time formats: 100 (ms), 100ms, 1s, 1m, raf, idle, tick

Add -immediate for leading edge (fires first, then blocks):

<button :onclick.debounce-100-immediate="submit()">Submit</button>

.throttle

Limit call frequency.

<div :onscroll.throttle-100="updatePosition()">...</div>
<div :onmousemove.throttle-raf="trackMouse">...</div>

.delay

Delay each call.

<div :onmouseenter.delay-500="showTooltip = true">Hover me</div>

.once

Run only once.

<button :onclick.once="init()">Initialize</button>
<img :onload.once="loaded = true" />

.window .document .body .parent .self

Change event target.

<div :onkeydown.window.escape="close()">...</div>
<div :onclick.self="handleClick()">Only direct clicks</div>

.away

Trigger when clicking outside element.

<div :onclick.away="open = false">Dropdown content</div>

.prevent .stop .stop-immediate

Control event propagation.

<a :onclick.prevent="navigate()" href="/fallback">Link</a>
<button :onclick.stop="handleClick()">Don't bubble</button>

.passive .capture

Listener options.

<div :onscroll.passive="handleScroll">...</div>

Key Filters

Filter keyboard events: .enter, .esc, .tab, .space, .delete, .arrow, .ctrl, .shift, .alt, .meta, .digit, .letter

<input :onkeydown.enter="submit()" />
<input :onkeydown.ctrl-s.prevent="save()" />
<input :onkeydown.shift-enter="newLine()" />

Store & Signals

Sprae uses signals for fine-grained reactivity.

import { signal, computed, effect, batch } from 'sprae'

// Create reactive values
const count = signal(0)
const doubled = computed(() => count.value * 2)

// React to changes
effect(() => console.log('Count:', count.value))

// Update
count.value++

// Batch multiple updates
batch(() => {
  count.value++
  count.value++
}) // Effect runs once

Store

store() creates reactive objects from plain data:

import sprae, { store } from 'sprae'

const state = store({
  count: 0,
  items: [],

  // Methods
  increment() { this.count++ },

  // Getters become computed
  get double() { return this.count * 2 },

  // Underscore prefix = untracked
  _cache: {}
})

sprae(element, state)

state.count++           // Reactive
state._cache.key = 1    // Not reactive

Signals

Replace built-in signals with any preact-signals compatible library:

import sprae from 'sprae'
import * as signals from '@preact/signals-core'

sprae.use(signals)
Library Size Notes
Built-in ~1kb Default, good performance
@preact/signals-core 1.5kb Industry standard, best performance
ulive 350b Minimal, basic performance
signal 633b Enhanced performance.
usignal 955b Optimized, async effects support

Utils

import { throttle, debounce } from 'sprae'

const search = debounce(query => fetch('/search?q=' + query), 300)
const scroll = throttle(updatePosition, 100)

Configuration

Custom Evaluator

Default uses new Function (fast but requires unsafe-eval CSP). Use jessie for strict CSP:

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'

// Simple: return update function
directive.id = (el, state, expr) => value => el.id = value

// With state access
directive.log = (el, state, expr) => {
  const evaluate = parse(expr)
  return () => console.log(evaluate(state))
}

// With cleanup
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))
modifier.uppercase = (fn) => (v) => fn(String(v).toUpperCase())
<button :onclick.log="save">Save</button>
<span :text.uppercase="name"></span>

Integration

JSX / React / Next.js

Use custom prefix to avoid JSX attribute conflicts:

// 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)
export default function Page() {
  return <nav>
    <a href="/" x-class="location.pathname === '/' && 'active'">Home</a>
    <a href="/about" x-class="location.pathname === '/about' && 'active'">About</a>
  </nav>
}

SSR / Hydration

Server renders HTML, sprae hydrates interactive parts:

// Server component — no 'use client' needed
export default function Counter() {
  return <div x-scope="{count: 0}">
    <button x-onclick="count++">
      Clicked <span x-text="count">0</span> times
    </button>
  </div>
}

Tip: Include default content inside elements. Directives replace it on hydration, providing graceful fallback if JS fails.

Web Components

Sprae works with shadow DOM. Initialize in connectedCallback:

class MyComponent extends HTMLElement {
  connectedCallback() {
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' }).innerHTML = `
        <div :scope="{ msg: 'Hello' }">
          <span :text="msg"></span>
        </div>
      `
      sprae(this.shadowRoot)
    }
  }
}
customElements.define('my-component', MyComponent)

Or pass state directly:

class Counter extends HTMLElement {
  connectedCallback() {
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' }).innerHTML = `
        <button :onclick="count++">Count: <span :text="count"></span></button>
      `
      this.state = sprae(this.shadowRoot, { count: 0 })
    }
  }
}
customElements.define('my-counter', Counter)

Recipes

Tabs

<div :scope="{ tab: 'one' }">
  <button :class="{ active: tab === 'one' }" :onclick="tab = 'one'">One</button>
  <button :class="{ active: tab === 'two' }" :onclick="tab = 'two'">Two</button>

  <section :if="tab === 'one'">Content One</section>
  <section :else>Content Two</section>
</div>
<div :scope="{ open: false }">
  <button :onclick="open = true">Open Modal</button>

  <dialog :if="open" :onclick.self="open = false" :onkeydown.window.escape="open = false">
    <h2>Title</h2>
    <p>Content</p>
    <button :onclick="open = false">Close</button>
  </dialog>
</div>
<div :scope="{ open: false }">
  <button :onclick="open = !open" :onfocusout.delay-100="open = false">
    Menu ▾
  </button>

  <ul :if="open">
    <li><a href="#">Option 1</a></li>
    <li><a href="#">Option 2</a></li>
    <li><a href="#">Option 3</a></li>
  </ul>
</div>

Form Validation

<form :scope="{ email: '', valid: false }" :onsubmit.prevent="valid && submit()">
  <input
    type="email"
    :value="email"
    :oninput="e => (email = e.target.value, valid = e.target.checkValidity())"
  />
  <button :disabled="!valid">Submit</button>
</form>

Infinite Scroll

<div :scope="{ items: [], page: 1 }"
     :fx="load()"
     :ref="el => {
       const io = new IntersectionObserver(([e]) => e.isIntersecting && load())
       io.observe(el.lastElementChild)
       return () => io.disconnect()
     }">
  <div :each="item in items" :text="item.name"></div>
  <div>Loading...</div>
</div>

<script>
  async function load() {
    const data = await fetch('/api?page=' + page++).then(r => r.json())
    items.push(...data)
  }
</script>

Accordion

<div :each="item, i in items" :scope="{ open: i === 0 }">
  <button :onclick="open = !open">
    <span :text="item.title"></span>
    <span :text="open ? '−' : '+'"></span>
  </button>
  <div :if="open" :text="item.content"></div>
</div>
<div :scope="{ q: '', results: [] }">
  <input :value="q" :oninput.debounce-300="e => search(e.target.value)" placeholder="Search..." />

  <ul :if="results.length">
    <li :each="r in results" :text="r.title"></li>
  </ul>
  <p :else :if="q">No results</p>
</div>

<script>
  async function search(query) {
    results = query ? await fetch('/search?q=' + query).then(r => r.json()) : []
  }
</script>

Hints

FAQ

Why sprae?
Minimal syntax, non-disruptive HTML. No build, no config. Ecosystem-agnostic (CDN, ESM, JSX). Open, configurable. Preact-signals compatible. Fast, practical and safe.
Yet another framework?
Not a framework. A 5kb enhancer for existing HTML. No ecosystem lock-in, works alongside anything.
Is it slow?
No. See js-framework-benchmark — faster than Alpine, comparable to Vue.
Why not Alpine.js?
Simpler API, 3× lighter, ESM-first, open state, signals support, prop modifiers, aliases, event chains. See alpine.md for migration guide.
Why not vanilla JS?
createElement is wrong mantra. Just use framework.
Why not Next/React?
Sprae augments JSX, which can help server components. Some find react not worth the time.
Why signals?
It is the emerging standard for reactivity. Preact-signals provide minimal API surface.
Who cares for progressive enhancement?
PE is for long-lasting, low-maintenance, fail-proof, accessible, portable and SEO-optimized web.
Is it just a toy?
Fun to play, comes with state sandbox. But production-ready too.
Does it scale to complex state?
As far as you and CPU can handle it.
Is new Function unsafe?
If your HTML comes from strangers, there is safe evaluator for CSP. If you control your HTML, it’s no different from inline onclick handlers.
Learning curve?
If you know HTML and JS, you know sprae. No new syntax, no special compilation, just :attribute="expression".
Components support?
Manage duplication, otherwise plop a web component.
TypeScript support?
Yes, full types included. If you need more please leave a request.
Browser compatibility?
Any browser with Proxy support.
Is it stable?
Yes, since v10.
How old is it?
3+ years old (first commit Nov 7, 2022).
Will it be maintained long-term?
12 versions, 1.5k+ commits and future plans.
What’s future plan?
Plugins, components, integration cases, generators.
Is it backed by a company?
No, indie. You can support it!

Comparison

  Sprae Alpine Petite-Vue
Size (min+gz) ~5kb ~16kb ~6kb
Signals Pluggable Custom @vue/reactivity
CSP Support Full Limited No
TypeScript Full Partial No
Maintained
Event Modifiers 10+ Limited Few
Custom Directives Yes Yes Limited
No-build Required
SSR-friendly Limited

Coming from Alpine? See alpine for a step-by-step migration guide.