Tiny attribute-based behavior + reactivity library. HTML is the source of
truth — you write real markup, ae attaches behavior to it. No virtual DOM,
no hydration, no template syntax, no build step required.
3.9 KB min+gzip · zero dependencies · TypeScript · one data-ae attribute
npm install @aeroapp/ae
yarn add @aeroapp/ae
pnpm add @aeroapp/ae
bun add @aeroapp/aeOr straight from a CDN, no tooling at all — esm.sh, unpkg, and jsDelivr all serve it the moment you need it:
<script type="module">
import { ae } from 'https://esm.sh/@aeroapp/ae';
// or: 'https://unpkg.com/@aeroapp/ae' (minified build)
// or: 'https://cdn.jsdelivr.net/npm/@aeroapp/ae/+esm'
</script>Pin an exact version in production — https://esm.sh/@aeroapp/ae@X.Y.Z (see
the npm badge above for the current release).
<button data-ae="save">Save</button>
<span data-ae="status"></span>import { ae } from '@aeroapp/ae';
const count = ae.signal(0);
ae('status').render(el => el.textContent = `${count.value} items`);
ae('save')
.press(() => count.value++)
.hover(el => el.classList.add('hot'), el => el.classList.remove('hot'));Handles are live: elements added to the DOM later — by you, by .list(),
by anything — get bound automatically via one shared MutationObserver, and
cleaned up completely when they leave. No manual unbinding, no leaks.
Signals — ae.signal(v), ae.computed(fn), ae.effect(fn). Writes are
batched per microtask; computeds are lazy and only notify when their value
actually changes.
Rendering — .render(fn) auto-tracks every signal read inside and re-runs
on change. Sugar helpers .text() / .cls() / .attr() / .show() accept a
plain value (applied once), a signal, or a function (both reactive).
Events — .press(fn) is semantic activation: click everywhere, plus
native-like Enter/Space only for elements the browser doesn't natively
activate (keyboard-accessible div[tabindex], [role=button] for free, no
double-fire on real buttons). Enter fires on keydown, Space on keyup —
exactly like a native button — and keys inside nested form controls or
editable text are never hijacked. .hover(enter, leave) and the .on(type, fn, opts) escape hatch round it out — all listeners per element, so
non-bubbling events just work.
Keyed lists — .list(items, render, key) stamps a native <template>
per item with keyed reconciliation: reorders move nodes without remounting,
unchanged items don't re-render, removed items are disposed.
<ul data-ae="todos">
<template><li><b data-ae="title"></b></li></template>
</ul>ae('todos').list(todos, (li, todo, i) => {
ae.parts(li).title.textContent = `${i + 1}. ${todo.text}`;
}, todo => todo.id);
ae('remove').press(btn => { // which item was clicked?
const todo = ae.itemOf(btn); // ae knows — no key stamping
todos.value = todos.value.filter(t => t.id !== todo.id);
});Two-way forms — .input(signal) wires by field type: strings for
text/select, booleans for checkboxes, real numbers for number/range, a group
value for radios, and string[] for <select multiple>. Writes are
equality-guarded so echoes never move the caret.
Scoped roots — ae(name, root) limits a handle to descendants of root:
per-list behavior without global name collisions.
Shadow DOM — ae.observe(shadowRoot) extends the same liveness into a
shadow tree: existing marked content mounts immediately, host removal cleans
everything up, and the returned disposer undoes it all.
Animation — ae.transition(fn) runs your signal writes inside a browser
View Transition: list enters, exits, and reorders animate in pure CSS, and
elements with a view-transition-name morph — even across lists. Falls back
to a plain call where unsupported.
Every member gets a signature, its parameters, its sharp edges, worked examples, and live demos:
| page | covers |
|---|---|
| Handles & lifecycle | ae(name), ae(name, root), .mount, .scope, .els, .each, ae.parts, ae.itemOf, ae.observe |
| Signals & scheduling | ae.signal, ae.computed, ae.effect, ae.isSignal, ae.settled, ae.transition, Reactive<T> |
| Rendering & events | .render, .text, .cls, .attr, .show, .press, .hover, .on |
| Forms | .input for text, checkbox, number/range, radio groups, and <select multiple> |
| Lists | .list, the template, keys, reconciliation, nested lists, animation |
Read it at https://nft.github.io/ae/reference.html, or locally with
bun run serve → http://localhost:4242. Full prose spec and design notes:
API.md. Compact API reference for AI agents:
llms.txt.
Real app: examples/kanban.html — a kanban with drag & drop, dynamic columns, inline editing, undo, filtering, and localStorage persistence in ~170 lines of JS. One-pager with live demos: site/index.html (deployed to GitHub Pages).
Keyed-list stress numbers, measured end-to-end — signal write through
reconciliation, mount pipeline, layout, and paint (headless Chrome, Apple
M4 Max; median of 3 runs). Each row carries three live data-ae bindings,
two of them event listeners.
| operation | time |
|---|---|
| create 1,000 rows | 26 ms |
| create 10,000 rows | 262 ms |
| append 1,000 to 10,000 | 69 ms |
| update every 10th of 11,000 | 117 ms |
| swap 2 rows of 11,000 | 75 ms |
| clear 11,000 rows | 58 ms |
Reproduce: bun run serve → examples/bench.html (add ?auto for the
full suite).
bun install
bun run build # tsc → dist/*.js + declarations (entry: dist/ae.js)
bun run test # jsdom smoke suite (build first)
bun run test:browser # Playwright: Chromium, Firefox, WebKit
bun run serve # demo at http://localhost:4242
bun run min # dist/ae.min.js- Batching — signal writes coalesce; each effect runs at most once per flush.
- Absolute disposal — a disposed effect never runs again, even if already queued.
- Net-state lifecycle — mount/cleanup reflect the net DOM change per task:
moves don't remount,
a→b→arenames are no-ops. - Fault isolation — one throwing effect/binding/cleanup never takes down the rest.
- Runaway guard — a self-triggering effect trips a circuit breaker instead of hanging the tab.