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
});
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.
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`);
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.
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.
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)));
empty
Drag cards between columns, or onto a card to reorder · click ● to cycle priority · every move is a View Transition.