The HTML you wrote
is the app.

ae attaches behavior to real markup through one data-ae attribute. Signals, keyed lists, two-way forms — no virtual DOM, no hydration, no build step.

npm i @aeroapp/ae or one line, no tooling: import { ae } from 'https://esm.sh/@aeroapp/ae'

Every demo on this page is live — the page itself runs on ae.

<button data-ae="save">Save</button>
<span data-ae="status"></span>

const saves = ae.signal(0);

ae('status').text(() => saves.value
  ? `saved ${saves.value}×`
  : 'never saved');

ae('save').press(() => saves.value++);
live

HTML first

Markup is the source of truth. You write real elements and real <template> tags; ae binds behavior to them. View source and it's all there.

Handles are live

One shared MutationObserver powers everything: elements added later bind automatically, removed ones clean up completely. No manual unbinding, no leaks.

Small on purpose

3.9 KB min+gzip, zero dependencies, written in TypeScript. The whole API fits on a card — and it's further down this page.

ae.signal · ae.computed · .render

Three writes, one render

Signal writes coalesce per microtask, and computeds only notify when their value actually changes. Press +3: the count jumps by three, the render counter ticks by one.

const count = ae.signal(0);
const double = ae.computed(() => count.value * 2);

ae('count').text(count);
ae('double').text(double);

ae('plus3').press(() => {
  count.value++;   // three writes …
  count.value++;
  count.value++;   // … flush once, next microtask
});
count
double
renders
.list · ae.parts · ae.itemOf

Keyed lists, native templates

.list() stamps the container's <template> per item with keyed reconciliation. Shuffle moves nodes — each row's stable #id and mount time travel with it, proving nothing remounts. And handlers recover their item with ae.itemOf: no keys stamped into the DOM.

ae('todos').list(todos, (li, todo) => {
  const p = ae.parts(li);
  p.id.textContent = `#${todo.id}`;   // stable key
  p.title.textContent = todo.text;
}, (todo) => todo.id);

ae('remove').press((btn) => {
  const todo = ae.itemOf(btn);   // which one?
  todos.value = todos.value.filter((t) => t !== todo);
});

Nothing left — add one above.

.input

Forms that stay in type

.input(signal) wires by field type — a <select> speaks strings, checkboxes speak booleans, number and range fields speak real numbers. The two seat fields share one signal, so the total is plain arithmetic on signal values — no parseInt, no string math, and equality-guarded writes mean echoes never move your caret.

const plan = ae.signal('pro');     // <select> ↔ string
const seats = ae.signal(3);        // number + range share it
const yearly = ae.signal(false);   // checkbox ↔ boolean

ae('plan').input(plan);
ae('seats').input(seats);          // both fields, one signal
ae('yearly').input(yearly);

ae('total').text(() =>             // arithmetic, no parseInt
  `$${PRICE[plan.value] * seats.value
      * (yearly.value ? 10 : 12)}/yr`);

.scope · ae(name, root)

Same names, private state

Every widget below uses identical inner names. One .scope() call wires each stamped root with its own scoped handles and its own private signal — current widgets and any you add. Remove one and its scope tears down; the others don't even blink.

ae('widget').scope((el) => {
  const clicks = ae.signal(0);   // per widget

  ae('count', el).text(clicks);
  ae('inc', el).press(() => clicks.value++);
});

No widgets.

ae.transition

Animation state, zero choreography

Wrap the writes in ae.transition and the browser animates whatever changed — enters, exits, reorders — as View Transitions you style in pure CSS. A unique view-transition-name makes elements morph, even between two lists. Unsupported browsers simply apply the change instantly.

ae('deck').list(chips, (el, chip) => {
  el.textContent = chip.label;
  el.style.viewTransitionName = `chip-${chip.id}`;
}, (chip) => chip.id);

ae('mix').press(() => ae.transition(() => {
  chips.value = shuffled(chips.value);
}));

Chrome 111+ / Safari 18+ animate the morphs; elsewhere it's instant.

.list × .scope × ae.itemOf × ae.transition

All of it at once: kanban

Columns are a keyed list; each column's cards are another keyed list, wired per column by .scope() with identical inner names that never collide. Drag a card between columns — ae.itemOf resolves the card on the source and the column on the drop target. Drop onto another card and the same call resolves that card instead, so vertical reordering falls out for free. Every mutation morphs via ae.transition; undo is just a snapshot of two signals.

Want it full-screen, with persistence and inline editing? Open the standalone board →

ae('board').list(cols, renderCol, (c) => c.id);

ae('column').scope((colEl) => {     // once per column
  const colId = ae.itemOf(colEl).id;
  const mine = ae.computed(() =>
    cards.value.filter((c) => c.col === colId));

  ae('cards', colEl).list(mine, renderCard, (c) => c.id);
});

ae('cards').on('drop', (el, e) => { // itemOf on the target
  patch(dragging, { col: ae.itemOf(el).id });
});

ae('card').on('drop', (el, e) =>    // …and on a card:
  reorder(dragging, ae.itemOf(el), isBelow(el, e)));

Drag cards between columns, or onto a card to reorder · click ● to cycle priority · every move is a View Transition.

docs

The whole API on one card

Every member is on this card — and every one of them has a page in the reference, with signatures, gotchas and live examples. The prose spec is API.md.

Global

ae(name)
live handle for every data-ae="name", present or future
ae(name, root)
same, scoped to descendants of root
ae.signal(v)
reactive value; writes batch per microtask
ae.computed(fn)
lazy derived value; notifies only on real change
ae.effect(fn)
auto-tracked side effect; returns dispose
ae.parts(root)
named data-ae descendants of a stamped node
ae.itemOf(el)
the current list item an element belongs to
ae.transition(fn)
run writes inside a View Transition; enters, exits, and moves animate in CSS
ae.settled()
resolves when writes and mounts have drained
ae.isSignal(v)
true for signals and computeds

Handle — everything chains

.render(fn)
auto-tracked render per element
.mount(fn)
setup per element; return a cleanup
.scope(fn)
per-root setup whose scoped handles retire on unmount
.press / .hover / .on
semantic activation · pointer enter/leave · any event
.text .cls .attr .show
sugar; take a value, a signal, or a function
.input(signal)
two-way, typed by field kind
.list(items, render, key)
keyed template stamping with node reuse
.els / .each(fn)
escape hatches to the raw elements

Every member, explained — with examples you can press

Five pages: signature, parameters, sharp edges and working samples for each method, plus live demos on the same dist/ae.js this page runs on.

guarantees

Semantics you can lean on

Batching

Writes coalesce; each effect runs at most once per flush.

Absolute disposal

A disposed effect never runs again — even if it was already queued.

Net-state lifecycle

Moves don't remount; a→b→a renames are no-ops. Only net DOM change counts.

Fault isolation

One throwing binding, cleanup, or effect never takes down the rest.

Runaway guard

Self-triggering effects trip a circuit breaker instead of hanging the tab.

Measured, not promised

10,000 keyed rows created in ~257 ms end-to-end, cleared in ~50 ms. Run the bench →