reference · 3 of 5

Rendering & events

Eight handle methods that push values into elements and pull events back out. Every one of them attaches per element through the mount pipeline, so elements added later bind themselves, removed ones tear down completely, and you never re-bind by hand.

.render is the primitive: your callback runs inside an auto-tracked effect. .text, .cls, .attr and .show are sugar over it — anything they do you can write by hand inside a render — and the three event methods are .mount plus an addEventListener paired with its removal. All eight append a binding and return the handle, so calls chain, and calling one twice adds a second binding rather than replacing the first.

.render(fn)

Runs fn(el) once per matching element inside its own auto-tracked effect. Every signal read during the run becomes a dependency of that element's effect, and a write re-runs it — batched, so one flush means at most one run. Elements connected later get their own effect when they mount; an element that leaves the DOM has its effect disposed, and a disposed effect never runs again, even if a write had already queued it.

.render(fn: (el: HTMLElement) => void): this
fn
the render callback, called with the element; its return value is ignored — the binding's cleanup is the effect disposal, so put teardown in .mount
returns
the same Handle, for chaining
const user = ae.signal({ name: 'Ada', unread: 3 });

ae('inbox').render((el) => {
  el.textContent = `${user.value.name} — ${user.value.unread} unread`;
  el.classList.toggle('has-unread', user.value.unread > 0);
});

user.value = { ...user.value, unread: 0 };   // re-renders on the next microtask

Reach for .mount when the work is one-time setup, and for .render when the element has to follow a value:

.mount(fn)
once per element, tracks nothing, may return a cleanup
.render(fn)
once per element and again per change, tracks every signal it reads
Gotcha — tracking is per run. Only the signals read on the last run are dependencies, so a branch that stops reading a signal stops depending on it. And a render that writes a signal it also reads trips the runaway guard after 100 flush cycles with a console.error instead of hanging the tab — see tracking rules.
Faults are isolated — a throw on the first run is logged as [ae] binding threw:, a throw on a later run as [ae] effect threw:. Either way the other elements, and the other bindings on this element, keep working.

.text(v)

Sets textContent = String(v) on every element in the handle. Like the other three helpers it takes the Reactive<T> union, so one method covers a constant, a signal and a derived expression.

.text(v: Reactive<string | number>): this
v
a plain value (applied once, at mount), a Signal/Computed (reactive), or (el) => value (run in an auto-tracked effect, so signals read inside make it reactive)
returns
the same Handle, for chaining
const count = ae.signal(0);
const label = ae.computed(() => `${count.value} item${count.value === 1 ? '' : 's'}`);

ae('brand').text('ae');          // plain value — set once, at mount
ae('count').text(count);         // signal — reactive
ae('summary').text(label);       // computed — reactive
ae('badge').text(() => count.value > 9 ? '9+' : count.value);   // function — tracked
Gotcha — writing textContent replaces every child of the target, so .text is for leaf elements. It never parses markup either: a value containing <b> shows up as those three characters, which is what makes it injection-safe.

.cls(name, on?)

Two methods in one signature. With on supplied it is classList.toggle(name, !!value) over the reactive union — the class follows the value, and any truthy value counts. With on omitted it is a one-shot classList.toggle(name) per element at mount time: no effect, no tracking, no reactivity.

.cls(name: string, on?: Reactive<boolean>): this
name
the class to toggle
on
optional; value, signal, or (el) => boolean. Omit it for the one-shot flip
returns
the same Handle, for chaining
const filter = ae.signal('all');
const busy = ae.signal(false);

ae('tab').cls('active', (el) => el.dataset.filter === filter.value);   // per element
ae('form').cls('is-busy', busy);
ae('legacy-row').cls('js-enhanced');   // one-shot: flipped once, at mount
Gotcha — the one-shot form toggles, it does not add. On an element that already carries the class it removes it, and because bindings re-run on every mount, an element that is removed and re-inserted flips again. When you mean "add", write .cls('x', true).
Note — the function form is per element: it receives the element, so one handle over many elements can decide each one's class from its own markup, as the tab example does.

.attr(name, v)

Sets one attribute from a reactive value, with the absence of the attribute as a first-class state — which is what HTML's boolean attributes actually need.

.attr(name: string, v: Reactive<string | number | boolean | null | undefined>): this
name
the attribute to write
null undefined false
removeAttribute(name)
true
setAttribute(name, '') — the empty string, as in disabled=""
anything else
setAttribute(name, String(value))
returns
the same Handle, for chaining
const busy = ae.signal(false);
const user = ae.signal({ avatar: null });

ae('save')
  .attr('disabled', busy)                          // true → disabled="" , false → removed
  .attr('aria-busy', () => String(busy.value));    // ARIA wants the string "true"/"false"

ae('avatar').attr('src', () => user.value.avatar ?? null);   // null removes the attribute
Gotcha — ARIA attributes are not boolean attributes. .attr('aria-expanded', true) writes aria-expanded="", not the string "true" that assistive technology reads. Map it yourself, as above.
Gotcha — attributes are not properties. .attr('value', …) only seeds a field's initial value and stops mattering once the user types; for form state use .input, which writes the property.

.show(on)

Toggles the hidden property — el.hidden = !value. The element stays in the DOM, so it stays mounted and its other bindings keep running; only its rendering is suppressed. To take an element out of the document entirely, drive a .list instead.

.show(on: Reactive<boolean>): this
on
value, signal, or (el) => boolean; truthy shows, falsy hides
returns
the same Handle, for chaining
<ul data-ae="todos">
  <li data-ae="empty">Nothing left.</li>
  <template><li data-ae="row"></li></template>
</ul>

ae('empty').show(() => todos.value.length === 0);
ae('spinner').show(busy);
Gotchahidden hides through the user-agent rule [hidden] { display: none }, and any display your own CSS sets on that element wins over it. An element carrying a flex or grid utility class stays visible while hidden; put the display utility on a wrapper, or add [hidden] { display: none !important } to your stylesheet.

live

One signal, four helpers: .text writes the count, .cls tints it once it moves, .attr adds and removes disabled, and .show reveals Reset and the badge.

full — the button now has disabled=""

.press(fn)

Activation, not click. Every element gets a click listener; Enter and Space are synthesized only for elements the browser does not activate on its own. button, a[href], input, select, textarea, summary and elements whose contenteditable is "", true or plaintext-only are excluded, for one of two reasons: on button, summary, a[href] and the button-like inputs the browser already turns those keys into a click, so a second listener would double-fire; in a text field or an editable region the keys mean text entry, and a keydown that calls preventDefault on Space would hijack typing.

.press(fn: (el: HTMLElement, ev: PressEvent) => void): this type PressEvent = MouseEvent | KeyboardEvent
fn
handler, called with the element and the originating event — a MouseEvent for a click, a KeyboardEvent for Enter and Space
returns
the same Handle, for chaining

The synthesized half copies what a native button does:

click
fires for every element, native or not
Enter
fires on keydown and calls preventDefault; a held key repeats, so it fires again per repeat
Space
fires once on keyup; its keydown only calls preventDefault, to stop the page scrolling
blur
disarms a pending Space, so moving focus mid-press cancels the activation
nested keys
keys whose target is a nested native control or editable text (inherited contenteditable included) are ignored and left un-prevented, so typing inside a pressable card is never hijacked
<div data-ae="tile" role="button" tabindex="0">Archive</div>
<button data-ae="tile">Archive</button>

const pinned = ae.signal(false);
const via = ae.signal('');

ae('tile').press((el, ev) => {
  pinned.value = !pinned.value;
  via.value = ev.type;    // 'click', or 'keydown' (Enter) / 'keyup' (Space)
});
Gotcha — ae adds behavior, not accessibility metadata. A div you make pressable still needs tabindex="0" so it can be focused and a role so it is announced as a control — both written by you. When a real <button> fits, use one and get all of it for free.

live

Both controls carry data-ae="b-p-tile", so one .press call drives both. Click them — then press Tab until the div has focus and hit Space or Enter.

div[role=button]
last event:

.hover(enter, leave)

Binds pointerenter and pointerleave on the element itself. Neither event bubbles, which makes nesting safe by construction: moving the pointer from a card onto a button inside it does not fire the card's leave, and the button's own .hover never reaches the card. Both callbacks are optional — pass undefined for the one you do not need.

.hover( enter?: (el: HTMLElement, ev: PointerEvent) => void, leave?: (el: HTMLElement, ev: PointerEvent) => void, ): this
enter
optional; runs on pointerenter
leave
optional; runs on pointerleave
returns
the same Handle, for chaining
const preview = ae.signal(null);

ae('card').hover(
  (el) => { preview.value = ae.itemOf(el); },
  () => { preview.value = null; },
);

ae('row').hover(undefined, (el) => el.classList.remove('menu-open'));   // leave only
Gotcha — pointer events cover mouse, pen and touch: a tap fires enter on contact and leave when the finger lifts. Treat hover as an enhancement and keep the same state reachable through .press or focus, or half your users will never see it.
Note — plain CSS :hover is still cheaper. Use .hover when the pointer has to change a signal, not when it only has to change a colour.

.on(type, fn, opts?)

The escape hatch: any DOM event type, attached per element through the mount pipeline and removed on unmount. Because nothing is delegated, non-bubbling events such as focus, blur and pointerenter work, stopPropagation behaves the way the platform says it does, and nested data-ae elements never shadow each other's listeners.

.on<K extends keyof HTMLElementEventMap>( type: K, fn: (el: HTMLElement, ev: HTMLElementEventMap[K]) => void, opts?: AddEventListenerOptions, ): this .on(type: string, fn: (el: HTMLElement, ev: Event) => void, opts?: AddEventListenerOptions): this
type
any event name; a name in HTMLElementEventMap picks the typed overload
fn
handler, called with the element and the event
opts
a standard AddEventListenerOptionsonce, passive, capture — passed straight through to addEventListener and to its removal
returns
the same Handle, for chaining
const draft = ae.signal('');
const hint = ae.signal(false);

ae('draft')
  .input(draft)
  .on('keydown', (el, ev) => { if (ev.key === 'Enter') add(draft.value); })
  .on('focus', () => { hint.value = true; })     // focus does not bubble —
  .on('blur', () => { hint.value = false; });    // a per-element listener gets it

ae('feed').on('scroll', onScroll, { passive: true });
ae('tour').on('click', () => { seen.value = true; }, { once: true });
ae('picker').on('item:picked', (el, ev) => { chosen.value = ev.detail.id; });
Gotcha{ once: true } is once per element per mount. The listener is attached fresh every time the element enters the DOM, so an element that is removed and re-inserted — a .list node whose key vanished and came back, for instance — fires again.
Note — the typed overload only covers names in HTMLElementEventMap. A custom event name falls through to the string overload, where ev is a plain Event; narrow it to CustomEvent yourself to reach detail.

live

Focus the field and tab away: focus and blur never bubble, yet the per-element listeners see both. The button next to it is bound with { once: true }.

focused: ·