npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sprae

v13.9.5

Published

Reactive HTML attributes. No build step, signals-based, CSP-safe.

Readme

∴ sprae tests size deps npm license

DOM microhydration. Add reactivity to the HTML via :-attributes.

  • No build step, no new syntax.
  • Signals-based and pluggable.
  • Full-JS CSP build for strict env and browser extensions.

Use for server-rendered pages, static sites, or prototypes – anywhere a full framework is overkill, with any backend and +JSX. A lightweight Alpine.js alternative.

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>

With an ES 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>

<!-- reusable: a function by name is called with the new scope -->
<div :scope="counter">...</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

Run a non-reactive lifecycle hook once when the element connects. The hook can return a cleanup function.

<canvas :mount="el => initChart(el)"></canvas>
<div :mount="el => {
  const timer = setInterval(tick, 1000)
  return () => clearInterval(timer)
}"></div>

:intersect

Run an expression when the element enters the viewport. A function receives the observer entry.

<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 values. Properties prefixed with _ 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 the 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 | Compatibility target | | ulive | 350b | Smallest | | signal | 633b | Minimal | | usignal | 955b | Async effects |

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

Keep server components and let sprae handle client-side interactivity without '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 without 'use client'
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>

Sprae works with Jekyll, Hugo, Eleventy, and Astro. Its own site uses this setup.

Server Templates

PHP, Django, Rails, and Jinja can render the HTML while sprae handles client-side 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 a custom element as a boundary. Directives set its props, but sprae does not descend into its 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]()
  • :each keys object items by identity and primitives by position; no key is needed.
  • this refers to current element, but prefer :ref or :mount for element access.
  • Properties prefixed with _ are untracked.

FAQ

How does it compare to Alpine? Sprae is ~1.7× smaller, ~2.8× faster, and uses ~4.5× less runtime memory in this comparison. It has pluggable signals, built-in modifiers, event chains, and a full-JS CSP build.

How does it compare to React/Vue? In the official js-framework-benchmark (Chrome 150), sprae places ahead of Vue, Preact and React in CPU speed, at 11.6kb compressed, with no build step or virtual DOM. In JSX, it adds client-side interactivity without 'use client'.

Why signals? Signals have a TC39 proposal, and sprae accepts any Preact Signals-compatible implementation.

Is new Function unsafe? new Function executes directive expressions as JavaScript. Use the default build only with trusted markup; under strict CSP, use the CSP build.

Components? Reusable state is a plain function: <div :scope="dropdown">, no registry. For simpler cases, manage duplication with templates or includes. Otherwise use custom elements, via define-element or any CE library: sprae sets their props and stops at their boundary. Coming from Alpine.data()? See the migration guide.

TypeScript? Full types included.

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

Does it scale? State uses plain reactive objects. For complex apps, use store with computed getters and methods.

Is it production-ready? Sprae has 3+ years of releases across ~200 npm versions. It has no dependencies or open issues, and its tests include the CSP build.

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