Skip to content

API reference

Everything kerf exports, organized by module. Imported via import { … } from 'kerfjs' unless noted.

A reactive value. .value reads / writes; reads inside effect() / computed() are tracked.

computed<T>(fn: () => T): ReadonlySignal<T>

Section titled “computed<T>(fn: () => T): ReadonlySignal<T>”

A derived signal. Re-runs fn whenever any signal it reads changes. Read-only.

effect(fn: () => void | (() => void)): () => void

Section titled “effect(fn: () => void | (() => void)): () => void”

Run fn immediately, then re-run it whenever any signal it reads changes. Returns a disposer. fn may return a cleanup function, which runs before each re-run and on dispose (see docs/2-reactivity.md).

Run fn, deferring effect re-runs until fn returns. Multiple writes inside fn produce a single re-run.

interface Signal<T> {
value: T;
peek(): T; // read without subscribing
}
interface ReadonlySignal<T> {
readonly value: T;
peek(): T; // read without subscribing
}

selector<K>(source: ReadonlySignal<K>): Selector<K>

Section titled “selector<K>(source: ReadonlySignal<K>): Selector<K>”

Keyed equality against one signal, at O(1) per change. selector(source) returns a reader (key) => ReadonlySignal<boolean> whose signal is true exactly when source.value equals key. The selector subscribes to source once; a change notifies only the previous key’s and the next key’s subscribers. The plain pattern, computed(() => row.id === selectedId.value), subscribes every row to selectedId, so one selection re-evaluates all N rows.

const selectedId = signal<number | null>(null);
const isSelected = selector(selectedId);
each(rows.value, (row) => (
<tr class={computed(() => (isSelected(row.id).value ? "danger" : ""))}>…</tr>
), (row) => row.id);
  • Entries follow their subscribers. A key’s signal joins the selector when its first subscriber arrives and leaves when its last one goes, so removing a row (which disposes its bindings) releases its entry. The selector’s own subscription to source exists only while some key is watched.
  • Key signals are always current. A held key signal read outside any effect returns the current comparison; unsubscribed, it reads the source lazily rather than following the selector’s flip. Signals handed out outside a subscriber are not cached.
  • Keys compare like Map keys (SameValueZero): primitives, or object references by identity. There is no custom equality, because a comparator would have to visit every key.
  • Selector<K> is the reader’s type: (key: K) => ReadonlySignal<boolean>.

setSelector<K>(source: ReadonlySignal<ReadonlySet<K>>): SetSelector<K>

Section titled “setSelector<K>(source: ReadonlySignal<ReadonlySet<K>>): SetSelector<K>”

Membership in a set signal, flipping only the members that changed. setSelector(source) returns a reader (key) => ReadonlySignal<boolean> whose signal is true exactly when source.value.has(key). One write diffs the previous set against the next (O(|old| + |new|)) and notifies only the subscribers of keys that were added or removed; a new Set with the same members notifies nothing. Use it for multi-select, a tree’s expanded rows, fields with errors, or a router’s active path prefixes.

const checked = signal<ReadonlySet<string>>(new Set());
const isChecked = setSelector(checked);
// in a row:
<tr class={computed(() => (isChecked(row.id).value ? "on" : ""))}>…</tr>
checked.value = new Set([...checked.peek(), "r-7"]); // notifies row r-7 only

It pays off when the sets are small next to the number of subscribed keys. Per-key signals follow their subscribers exactly as selector()’s do, and membership is Set.prototype.has. SetSelector<K> is the reader’s type.

arraySignal<T>(initial?: readonly T[]): ArraySignal<T> — kerfjs/array-signal subpath

Section titled “arraySignal<T>(initial?: readonly T[]): ArraySignal<T> — kerfjs/array-signal subpath”
import { arraySignal } from "kerfjs/array-signal";
const rows = arraySignal<{ id: number; label: string }>([]);

Granular collection signal. Lives in its own subpath — import { arraySignal } from 'kerfjs/array-signal' — so apps that don’t use it shed ~1 KB from the main barrel. Pair with each(...) inside a mount() for O(patches)-not-O(N) reconciles. See docs/2-reactivity.md for the rationale and gotchas, and docs/4-render.md (granular reconcile) for how the binding works.

class ArraySignal<T> {
readonly value: readonly T[]; // tracking read
update(index: number, fn: (item: T) => T): void; // → 1 update patch
insert(index: number, item: T): void; // → 1 insert patch
push(item: T): void; // sugar for insert(length, item)
remove(index: number): T; // → 1 remove patch (returns removed item)
move(from: number, to: number): void; // → 1 move patch (no-op if from === to)
replace(items: readonly T[]): void; // → 1 replace patch (forces snapshot reconcile)
}

Every indexed mutator requires finite integer indices in range and throws a descriptive Error before changing state or emitting a patch otherwise. move() validates both indices before applying its from === to no-op. Reads on arraySig.value register a tracking dependency just like signal.value — computed(() => arraySig.value.filter(...)) and effect(() => render(arraySig.value)) work the same way.

The ArraySignal<T> class is detected via Symbol.for('kerfjs.ArraySignal'), not instanceof, so multiple bundle copies still interoperate. The brand symbol itself is also exported as ARRAY_SIGNAL_BRAND from kerfjs/array-signal for consumers who build their own collection types and want each(...) to recognize them via brand check.

The mutator events are surfaced as the ArrayPatch<T> type — a tagged-union covering update / insert / remove / move / replace:

type ArrayPatch<T> =
| { type: "update"; index: number; item: T }
| { type: "insert"; index: number; item: T }
| { type: "remove"; index: number }
| { type: "move"; from: number; to: number }
| { type: "replace"; items: readonly T[] };

Most consumers never touch ArrayPatch directly — each(...) consumes the queue internally. Export the type when you want to observe patches from outside each() (e.g. logging, persistence layers, custom reconcilers).

defineStore<TState, TActions>(spec): Store<TState, TActions>

Section titled “defineStore<TState, TActions>(spec): Store<TState, TActions>”
defineStore({
initial: () => TState,
actions: (set: (next: TState) => void, get: () => Readonly<TState>) =>
TActions,
});

Creates a store with state: ReadonlySignal<TState>, actions: TActions, reset(): void. Registers in the global registry consumed by resetAllStores().

set(next) REPLACES state; it does NOT merge. Pass the full state object on every call, or use set({ ...get(), ...patch }) to merge. get() reads the current state untracked, so an action called inside an effect() or a mount() render never subscribes its caller to the store it writes (consumers subscribe through state.value). When the diagnostics are installed (import 'kerfjs/dev'), get() returns a deep read-only Proxy so that any mutation of it — including a nested get().nested.x = 1 — throws a TypeError; reads (spread, JSON.stringify, Object.keys, iteration) are transparent, and the live state object is never frozen. Production returns the bare reference for zero overhead. Opt in to the runtime narrow-set warning with enableWarnings({ narrowSet: true }) from kerfjs/dev (or KERF_DEV_WARN_NARROW_SET=1 under Node) to catch partial-set bugs at the moment they happen. The warning hook is resolved on every set() call, so a store created before kerfjs/dev is installed starts warning on later actions as soon as diagnostics are available (see Dev-mode warnings for the full family).

Calls reset() on every store registered via defineStore().

interface Store<TState, TActions> {
readonly state: ReadonlySignal<TState>;
readonly actions: TActions;
reset(): void;
}

clearStoreRegistry(): void — kerfjs/testing subpath

Section titled “clearStoreRegistry(): void — kerfjs/testing subpath”

Empties the global store registry. Used by unit tests to isolate cases. Imported via the kerfjs/testing subpath, not the main kerfjs entry, so production builds don’t pull it in:

import { clearStoreRegistry } from "kerfjs/testing";

kerfjs/dev subpath — install the development diagnostics

Section titled “kerfjs/dev subpath — install the development diagnostics”

kerf does not infer whether it is running in development. Importing this subpath is the development signal; omitting it is production. Gate the import with your own build’s dev flag, in your own code:

if (import.meta.env.DEV) await import("kerfjs/dev"); // Vite
if (process.env.NODE_ENV !== "production") await import("kerfjs/dev"); // webpack / Node

That condition folds to false in your production build, so the statement is eliminated and the chunk is never emitted or fetched — a realistic import is 12.24 KB min+gzip without it vs 16.91 KB when the diagnostics shipped in the main bundle. A no-build/CDN app imports it from its development page and omits it from the production page.

Installing enables: the whole KERF_DEV_WARN_* warning family (each still requires its own switch — see enableWarnings() below), the structural list-invariant checks, the read-only defineStore get() snapshot, and the throwing form of the dangerous-URL screen (production warns and drops instead).

enableWarnings(options: DevWarningOptions): void

Section titled “enableWarnings(options: DevWarningOptions): void”

Switches individual diagnostics on. Installing makes them PRESENT; this decides which are ON, so importing the entry never floods the console:

if (import.meta.env.DEV) {
const dev = await import("kerfjs/dev");
dev.enableWarnings({
staleBinding: true,
narrowSet: true,
invariants: "throw",
});
}

Keys (all optional, all off unless named): rebuiltListeners, untrackedSignals, narrowSet, delegateInEffect, eachInMorphSkip, duplicateEachKeys, staleBinding, valueOnlyRerender, listRebind, staleIndex, parserRepair, selectorFanout, and invariants (true warns, 'throw' throws). Each corresponds one-to-one with a KERF_DEV_WARN_* environment variable — those still work for Node, SSR, and CI — and an explicit call wins over the environment in both directions, so { narrowSet: false } silences an ambient variable.

In a browser, this is the only switch there is. A browser realm has no process object, and a bundler define cannot reach the read, so the environment variables are unreachable in the environment where these warnings are most wanted.

Call it as early as you can: most diagnostics read their switch at call time, but four are decided when their host is created — untrackedSignals per signal(), delegateInEffect per effect(), rebuiltListeners per mount(), and selectorFanout per signal() and computed(). Enabling untrackedSignals prints its coverage boundary once.

The subpath also re-exports clearDevHooks(): void, installDevHooks(hooks: DevHooks): void and devHooks so a consumer’s own test suite can assert production-shaped behavior without reloading modules. It also exports DEV_HOOKS, the standard hook bundle the entry installs on import: a suite can clearDevHooks() for a production-shaped test and installDevHooks(DEV_HOOKS) afterwards to restore it (re-importing kerfjs/dev would not reinstall, because a module evaluates once). installDevHooks() merges into the installed set rather than replacing it.

Order matters in four places, all decided at creation: signal() chooses its constructor (untracked signals, selector fan-out), computed() decides whether to count its re-evaluations (selector fan-out), effect() decides whether to wrap its body (delegate-in-effect), and mount() decides whether to attach the rebuilt-listeners observer. A signal, computed, effect, or mount created before the dev entry runs, or before its warning is switched on, stays uncovered — and since static imports hoist above a top-level await import(), that is the common case for module-scope code. To cover it, make import 'kerfjs/dev' the first static import of a dev-only entry file and call enableWarnings() there. Opting into the untracked-signals warning prints the boundary once so that gap is never silent. Every other hook is read at call time, including store hooks: an already-created store observes a later kerfjs/dev installation on its next get() or set() call. See docs/11-dev-warnings.md.

mount(rootEl: HTMLElement, render: () => MountResult): () => void

Section titled “mount(rootEl: HTMLElement, render: () => MountResult): () => void”
type MountResult = SafeHtml | string | number | boolean | null | undefined;

Bind render() to rootEl’s children. Wraps effect() with kerf’s segment-aware diff. Returns a disposer.

If rootEl belongs to an inert document (no browsing context — e.g. a DOMParser result, a <template>.content child, or document.implementation.createHTMLDocument() output), mount() adopts it into the live document first, so its first-render innerHTML write is safe on every engine (some engines mis-parse innerHTML on inert-document elements under rapid bursts). Roots that are already in the live document — the normal case — are untouched, and a live element in another realm (e.g. an iframe) is left in place. toElement() output is already adopted, so this only matters for hand-rolled roots.

Initial-render failure. If the first render throws (the render function, an each() row-contract violation, a binding whose first value throws), mount() rethrows the original error, returns no disposer, and rolls back: every effect and binding it wired is disposed, the dev listener observer is disconnected, the element gets back exactly the child nodes it had before the call, and it is no longer marked as mounted — so a retry on the same element succeeds. A throw from a later re-render leaves the mount live instead.

MountResult is wide enough that consumers can write () => cond ? <jsx/> : null and () => cond && <jsx/> without a sentinel — matching the React / Solid convention. null / undefined / false / true coerce to “render nothing” (empty string); numbers stringify; everything else falls through String(...). See docs/4-render.md (step 2 of the render pipeline) for the rationale and the equivalent fallback patterns. The MountResult type alias is exported from the main barrel for consumers that want to annotate their render functions explicitly.

The diff:

  • Only ever touches rootEl’s subtree; rootEl itself is preserved.
  • Matches elements by id, then data-key. Position otherwise.
  • Short-circuits on the live element when:
    • It has data-morph-skip (element AND subtree preserved as-is; no attribute morphing).
    • It has data-morph-skip-children (attributes morph; subtree preserved as-is).
    • fromEl.isEqualNode(toEl) (no work needed).
    • It’s the focused [contenteditable] (entire subtree preserved on this morph; see the Conventions used by mount section below and docs/4-render.md).
  • The trailing-removal pass (unmatched live children that the new template doesn’t emit) skips elements marked data-morph-preserve — imperatively-injected nodes whose lifetime the consumer manages outside kerf.
  • Otherwise preserves the focused text-entry’s value + selection range, then proceeds.
  • A list parent owned by each(...) is not short-circuited: the diff still morphs its attributes and walks all of its children, skipping only the rows each’s reconciler owns, so non-list siblings before or after the rows reconcile normally.

Lists rendered with each(...) go through a separate keyed reconciler that operates directly on the live parent’s children — O(changes), not O(rows). See each below.

morph(liveRoot: Element, template: Element | SafeHtml | string): void

Section titled “morph(liveRoot: Element, template: Element | SafeHtml | string): void”

One-shot in-place reconciliation primitive — the same algorithm mount() uses internally, exported for consumers that have an already-populated element they need to reconcile against a freshly-built template. Unlike mount(), morph() doesn’t wrap an effect() and doesn’t bulk-write innerHTML first: it runs once per call against the live tree as-is. When it mutates a checked / value / selected attribute it syncs the matching DOM property too, so controlled form state holds up after user interaction (the dirty-state flags would otherwise detach the visible state from the attribute). Only liveRoot’s children are reconciled: the root element is never replaced and its own attributes are never touched, so template describes the content inside the root (for an Element template, its child nodes are used and its own tag and attributes are ignored). Ordinary live attributes on descendants that the template omits are removed; the exceptions are user-agent-owned open on <details>, <dialog>, and custom elements (any hyphenated tag), and an element skipped by data-morph-skip.

import { morph, raw } from "kerfjs";
// `template` describes liveCard's CHILDREN; liveCard's own attributes are untouched
morph(liveCard, freshlyBuiltCardEl); // Element template: its child nodes are used
morph(liveCard, '<h2 class="card-title">…</h2><p>…</p>'); // raw HTML string
morph(liveCard, raw(htmlFromServer)); // SafeHtml

When template is a string or SafeHtml, kerf creates a transient element by cloning liveRoot’s shell (so the parsed children land inside an element with the same tag, which keeps innerHTML parsing rules consistent) and assigns the stringified template to its innerHTML. The transient is discarded after the reconciliation.

Every short-circuit mount()’s morph honors carries over: data-morph-skip (element + subtree preserved), data-morph-skip-children (attrs morph, subtree preserved), data-morph-preserve (element survives the trailing-removal pass), isEqualNode byte-identity skip, focused text-input value + selection preservation, focused-[contenteditable] subtree preservation, and the user-agent-owned open attribute on <details>, <dialog>, and custom elements, plus the element-owned role / aria-* on custom elements and the custom-element rule that removes only attributes the previous template rendered. Match keys (id, then data-key) behave the same way.

morph() does NOT subscribe to signals. If you want re-renders, use mount(). If you want a one-shot reconciliation against a tree you own, this is the primitive. See docs/4-render.md.

A null / undefined liveRoot throws immediately with a descriptive error (the usual cause is a getElementById typo returning null at runtime despite the TypeScript types) — the same guard mount() applies to its rootEl.

Security — trusted templates only. A string / SafeHtml template is parsed as HTML with no escaping and no URL screening — the same trust model as innerHTML / raw(). The attributes of an Element template’s descendants are copied to the live tree verbatim, including on* inline handlers and javascript: URLs. So a morph() template must be markup you trust: built via JSX, or sanitized upstream (DOMPurify). Never pass unsanitized user input as a morph() template. (This is distinct from the mount() render path, where JSX escapes values and screens dangerous URLs — morph() bypasses that because its template is already-built markup.)

renderDocument(node: SafeHtml | string, options?: RenderDocumentOptions): string

Section titled “renderDocument(node: SafeHtml | string, options?: RenderDocumentOptions): string”
import { renderDocument } from 'kerfjs';
return c.html(renderDocument(<Page />)); // "<!DOCTYPE html><html>…"

SSR convenience: prepends the doctype to a rendered document so routes don’t reinvent "<!DOCTYPE html>" + page.toString(). node is a SafeHtml (JSX or the html tagged template) or a raw string — both are stringified via .toString(). RenderDocumentOptions is { doctype?: string } (default 'html'). Pure string work; no DOM dependency.

each<T>(items, render, cacheKey?): SafeHtml

Section titled “each<T>(items, render, cacheKey?): SafeHtml”

The third argument may instead be an options object — each<T>(items, render, options?): SafeHtml — carrying cacheKey and/or key. Both forms are supported; the options form is what you need when a list requires a stable identity (see below).

each(rows.value, (row) => <tr data-key={row.id}>{row.label}</tr>);
each(rows.value, (row) => <tr…>…</tr>, (row) => row.id === selectedId ? 1 : 0);
each(rows.value, (row) => <tr…>…</tr>, { key: 'rows' });
each(rows.value, (row) => <tr…>…</tr>, { key: 'rows', cacheKey: (row) => row.id === selectedId });

A cacheKey over the selection re-renders the rows whose key changed. For selection, binding the row’s class to selector(selectedId) (see selector) keeps every row cached and updates only the two affected attributes.

Keyed list iteration with per-item memoization, routed through mount()’s native list reconciler. Skips re-running render for items whose object identity (and optional cacheKey) are unchanged since the previous call — those items keep their existing live DOM nodes verbatim. Items whose identity or cacheKey did change get a fresh node (all fresh-node HTML for a render is bulk-parsed in one innerHTML call); items that disappeared are removed. Reorders use a longest-increasing-subsequence pass so the number of insertBefore calls is the minimum possible. Items must be objects (cache is a WeakMap); wrap primitives if you need to iterate them. Each item’s render output must produce exactly one top-level element — and that element must survive HTML parsing as itself, so put an each() of <tr> inside an explicit <tbody> (a bare <table> makes the parser insert one, which kerf rejects with a precise error).

cacheKey is a passive comparator (not a reactive subscription): kerf calls it once per item per mount-effect run and compares the returned value against the previous run’s. Use it when external state, not the item itself, drives what the row should render (e.g. a “currently selected” id flips a CSS class). Distinct from data-key on the rendered element, which is the DOM-reconciliation identity that morph uses — cacheKey controls when the cached HTML is invalidated; data-key controls how a row maps to its existing live DOM node. (Renamed from key for clarity; positional callers — the canonical form — are unaffected.)

render receives (item, index). The index is the row’s position at render time; it is not part of the memo key (only item identity, cacheKey, and content version are). So a row that keeps its identity while its position changes — a reorder, or an insert/remove/move ahead of it — keeps the HTML it rendered at its old index, and any use of index in the output (a {index + 1}. prefix, zebra striping, an “N of M” label) goes stale on the moved rows. When the output depends on the index, fold it into the memo key so displaced rows re-render: each(items, render, { cacheKey: (_, i) => i }) (add a key if the list needs one). The opt-in enableWarnings({ staleIndex: true }) (or KERF_DEV_WARN_STALE_INDEX=1 under Node) surfaces the hazard at runtime. (One edge this workaround doesn’t cover — an arraySignal batch whose fresh inserts displace each other — is noted in docs/4-render.md; for index-labeled rows with multi-insert batches, prefer immutable signal<T[]> updates.)

key gives the list a stable identity. Without one, a list is identified by its call order — “the n-th each() in this render” — so any render that changes how many each() calls run before it reassigns its identity, and kerf rebuilds the list from scratch: rows lose their DOM nodes, and with them focus, scroll position and in-progress IME composition. The common trigger is a conditional list rendered above another list. Give a key to any list that can be preceded by one:

{
showFilters.value ? (
<ul>{each(filters.value, renderFilter, { key: "filters" })}</ul>
) : (
""
);
}
<ul>{each(results.value, renderResult, { key: "results" })}</ul>;

A keyed list does not occupy a call-order slot, so keying just the conditional list is usually enough — its unkeyed siblings stop shifting too. Keys must be unique within a mount; two lists claiming the same key throw. In development, kerf warns once per list when it detects an identity shift and names the fix.

A key must be a non-empty string of letters, digits, or _ . : / - and may not contain -- — kerf writes it into the list’s marker comment in the DOM, so anything that could terminate a comment is rejected with an error rather than corrupting the mount.

interface EachOptions<T> {
cacheKey?: (item: T, index: number) => unknown;
key?: string;
}

The options-object form of each()’s third argument, exported from kerfjs as a type-only export so a helper can accept or build the options without restating the shape:

import { each, type EachOptions } from "kerfjs";
const resultOptions: EachOptions<Result> = { key: "results", cacheKey: (r) => r.id === selectedId.value };
each(results.value, renderResult, resultOptions);

each() does not nest. A row’s HTML is flattened to a string, so an each() called inside a row render never binds — it would render as inert static markup. Render an inner collection with plain .map() (it re-renders with its row), or restructure to a flat list. A keyed nested each() throws and says so.

If a descendant of a moved row holds focus, the reconciler snapshots the active element + its selection range before the move pass and re-applies them afterwards — so focus and caret position survive a reorder even on engines that drop focus on insertBefore (older Safari, happy-dom). See docs/4-render.md.

delegate(rootEl, type, selector, handler, options?): () => void

Section titled “delegate(rootEl, type, selector, handler, options?): () => void”
delegate(rootEl, 'click', '[data-action="add"]', (event, matched) => { ... });
delegate(rootEl, 'focus', '.field-row', (event, row) => { ... });

One root listener with closest(selector)-style walk-up matching; fires handler(event, matched) if the match is inside rootEl. Returns a () => void disposer — capture it and call it when the delegate’s scope ends (closing a modal, leaving a route, tearing down a widget). Discarding the disposer is only safe for genuinely page-lifetime registrations: top-level mount on a root that never tears down. Everywhere else the closure pins rootEl, handler, and everything the handler closes over, so an undisposed listener leaks the app graph and re-mounts stack listeners. mount()’s disposer does NOT remove delegates for you. See docs/5-event-delegation.md — and its “When capturing the disposer still isn’t enough” section for the cluster of cases where capturing alone isn’t sufficient (delegate inside effect(), delegate on toElement() output that’s replaced, disposer variable overwrites, nested transient roots).

Auto-promotes the well-known non-bubbling event types (focus, blur, scroll, load, error, mouseenter, mouseleave) to capture phase under the hood, so the call site looks identical regardless of whether the event bubbles. Selector matching stays closest()-style for every event type — wrapper selectors still match when the event lands on a descendant.

selector is validated at registration: an invalid CSS selector throws an Error naming delegate and the selector immediately, rather than installing a listener that could never match (the same check applies to delegateCapture()).

The optional fifth argument is { match?: 'closest' | 'direct' } (see DelegateOptions below). It defaults to 'closest'; pass 'direct' to fire only when event.target itself matches the selector (no walk-up).

delegateCapture(rootEl, type, selector, handler, options?): () => void

Section titled “delegateCapture(rootEl, type, selector, handler, options?): () => void”

Same shape, but installs on the capture phase. Selector matching is closest()-style by default — the same walk-up as delegate(), passing the matched ancestor (not the raw target) to your handler. The escape hatch — use it for custom non-bubbling events that aren’t in delegate()’s auto-promotion list, or when you want capture-phase semantics (run before any descendant’s bubble-phase handler). Same disposer-capture rule as delegate(). Pass { match: 'direct' } to opt into strict target.matches(selector) matching (fire only on the exact element the selector identifies, no walk-up).

interface DelegateOptions {
match?: "closest" | "direct";
}

Options accepted by both delegate() and delegateCapture(). match selects how the selector is applied to event.target: 'closest' (the default) walks up via closest(selector) and fires for the nearest matching ancestor inside rootEl; 'direct' fires only when event.target itself matches the selector.

attr<N extends string, V extends string>(name: N, value: V): AttrSpec<N, V>

Create a pre-computed attribute descriptor. Escapes name and value once at definition time; the resulting AttrSpec is frozen and ready to use in both JSX and delegate().

import { attr, type AttrSpec } from 'kerfjs';
const ACTIONS = {
toggle: attr('data-action', 'toggle'),
remove: attr('data-action', 'remove'),
} as const satisfies Record<string, AttrSpec<'data-action'>>;
// In JSX — spread .attrs (rename-safe; no hardcoded attribute name at call sites):
<button {...ACTIONS.toggle.attrs}>Toggle</button>
// In delegate — use the pre-computed selector:
delegate(root, 'click', ACTIONS.toggle.selector, handler);
// → '[data-action="toggle"]'
attr<N extends string, V extends string = string>(name: N): (value: V) => { readonly [K in N]: V }

Pre-validates and pre-escapes the attribute name, then returns a factory for per-render values. Use for per-row data attributes like data-id where the value changes per item. Leaving both generics off infers N from the argument and defaults V to string; specify both explicitly to constrain which values the factory accepts.

const ITEM = { id: attr('data-id') } as const;
// In JSX — call the factory inline:
<li {...ITEM.id(String(item.id))}>…</li>

The attribute name is validated and CSS-escaped at creation; the factory result carries the raw value (escaped later by the JSX attribute renderer when spread). In the static form, both the name (CSS identifier) and value (double-quoted CSS string) are CSS-escaped into .selector at creation — SSR-safe, no CSS.escape dependency. Both forms throw on an empty attribute name.

For ad-hoc compound selectors, concatenate .selector strings:

delegate(
root,
"click",
ACTIONS.toggle.selector + attr("data-id", id).selector,
handler,
);
interface AttrSpec<N extends string = string, V extends string = string> {
readonly name: N; // raw attribute name
readonly value: V; // raw attribute value
readonly selector: string; // pre-computed '[name="value"]' selector string
readonly attrs: { readonly [K in N]: V }; // spreadable JSX object
}

Generic type parameter: delegate<T extends Element = Element>()

Section titled “Generic type parameter: delegate<T extends Element = Element>()”

Both delegate() and delegateCapture() accept an optional element-type generic that narrows the target argument in the handler, avoiding casts:

delegate<HTMLButtonElement>(root, "click", "button[data-action]", (e, btn) => {
// btn is HTMLButtonElement — no cast needed
btn.disabled = true;
});

The default is Element (untyped call sites are unaffected).

action<V extends string>(value: V): AttrSpec<'data-action', V> — kerfjs/actions subpath

Section titled “action<V extends string>(value: V): AttrSpec<'data-action', V> — kerfjs/actions subpath”
import { action, delegateActions } from "kerfjs/actions";

action('select-file') is a thin specialization of attr('data-action', 'select-file') — it returns the same AttrSpec. Spread its .attrs in JSX and use its .value as the handler-table key, so the action name lives in exactly one place and can’t drift between the markup and the dispatcher. Lives in its own subpath so apps that don’t use it pay nothing.

delegateActions<E extends Element = Element>(root, eventType, table, options?): () => void

Section titled “delegateActions<E extends Element = Element>(root, eventType, table, options?): () => void”
const A = { select: action("select"), remove: action("remove") };
// JSX: <button {...A.select.attrs} data-id={id}>…</button>
const dispose = delegateActions(root, "click", {
[A.select.value]: (_e, el) => select(el.getAttribute("data-id")),
[A.remove.value]: (_e, el) => remove(el.getAttribute("data-id")),
});

Wires a whole table of data-action handlers with ONE delegated listener (built on delegate(), so it inherits the single-listener dispatch and the capture auto-promotion for non-bubbling event types). On eventType, the nearest element carrying the action attribute (walk-up closest() by default; { match: 'direct' } for an exact match) is looked up in table by its attribute value and the matching handler runs; an action absent from the table is ignored, like a switch (dataset.action) with no matching case. Only the table’s own entries dispatch: a value naming an inherited Object.prototype member (toString, constructor, __proto__, hasOwnProperty) is ignored too. Returns a () => void disposer. One event type per call (mirroring delegate()) — collect the disposers when a root needs several. Options: { attr?: string; match?: 'closest' | 'direct' } — attr (default 'data-action') overrides the keyed attribute; match is inherited from DelegateOptions. The ActionHandler<E> and DelegateActionsOptions types are exported for annotating handlers and options.

import 'kerfjs/jsx-runtime' — TypeScript / esbuild config

Section titled “import 'kerfjs/jsx-runtime' — TypeScript / esbuild config”
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "kerfjs",
},
}
class SafeHtml {
readonly __html: string;
constructor(html: string);
toString(): string;
}

The return type of every JSX expression. .toString() returns the underlying HTML. (The listing above is the public contract, deliberately simplified: the emitted declaration’s constructor also accepts an internal Segment and the instance carries a __segment field — both internal plumbing for mount()’s list handling that consumers should not construct or read.)

SafeHtml instances carry a brand symbol — Symbol.for('kerfjs.SafeHtml') — so cross-bundle identification works even if a consumer’s bundler ends up loading two copies of kerf (e.g. the barrel and the JSX-runtime entry resolved as independent modules). Prefer isSafeHtml() over instanceof SafeHtml when writing custom integrations.

type JSXChildren =
| SafeHtml
| string
| number
| boolean
| null
| undefined
| ReadonlySignal<unknown>
| readonly JSXChildren[];

Canonical recursive type for a third-party function component’s children or equivalent general-purpose content slot. It matches the JSX runtime exactly, including readonly arrays nested to any depth. Boolean and nullish members render nothing; signals bind fine-grained inside mount() and snapshot outside it. Import it from kerfjs or kerfjs/jsx-runtime. Use a narrower type for a slot whose semantics intentionally admit fewer kinds of content.

isSafeHtml(value: unknown): value is SafeHtml

Section titled “isSafeHtml(value: unknown): value is SafeHtml”

Cross-bundle-safe type guard. Returns true for any object carrying the Symbol.for('kerfjs.SafeHtml') brand. Use this rather than instanceof SafeHtml if you’re inspecting JSX values yourself — instanceof fails when two copies of kerf produce structurally-identical-but-class-distinct SafeHtml instances.

Wrap a pre-escaped HTML string, bypassing kerf’s auto-escaping. Useful for icons, rendered Markdown, or server-included fragments.

Reach for raw() rarely. kerf escapes automatically everywhere else, so a codebase with a lot of raw() is usually reaching past a safer first-class tool:

  • Interpolating dynamic text or attributes? Plain JSX already escapes it (<p>{value}</p>, class={sig}) — you don’t need raw().
  • Composing markup? Build a SafeHtml the normal way — a JSX expression, the html tagged template (kerfjs/html), each(), or a component function returning JSX. All produce trusted SafeHtml without hand-writing an HTML string.
  • A genuinely trusted, pre-escaped dynamic value (server output, config, a hard-coded string) is the one legitimate use. The kerfjs/no-raw-with-dynamic-arg lint rule flags a raw() whose argument is not a literal — because unsanitized user input is the canonical XSS mistake. Acknowledge a call you’ve reviewed with an explicit // eslint-disable-next-line kerfjs/no-raw-with-dynamic-arg. That override is the single sanctioned way to say “this is trusted,” and it leaves a searchable audit trail. (The rule ships at warn in configs.recommended, so an un-acknowledged dynamic raw() is a nudge, not a hard failure — but the disable comment is how you signal intent.)

raw() is not a sanitizer — it does no escaping. For user-controlled input, sanitize first (raw(DOMPurify.sanitize(marked(userMarkdown)))) or, better, render it through escaping JSX instead.

JSX <>...</> — concatenates children without a wrapper tag. Available from both kerfjs/jsx-runtime (used by the JSX transform) and the main kerfjs barrel (when you need to compose Fragment manually, e.g. jsx(Fragment, { children })).

These exports are public and covered by semver, but they exist for build tools, the JSX type checker, and test harnesses rather than for application code. Use them only in the role described here.

  • jsx, jsxs, jsxDEV (kerfjs/jsx-runtime) — the functions the JSX transform emits calls to. jsxs (static children) and jsxDEV (development transforms) are aliases of jsx; kerf renders all three identically. You never call them by hand.
  • kerfjs/jsx-dev-runtime — the subpath a development-mode transform ("jsx": "react-jsxdev", Vite / vitest dev builds) imports. It is an alias of kerfjs/jsx-runtime: same module, same types, no configuration.
  • JSX.ElementChildrenAttribute — tells TypeScript that a component’s children arrive in its children prop. Type-checker plumbing only.
  • ARRAY_SIGNAL_BRAND (kerfjs/array-signal) — the brand symbol each() checks to recognize a granular collection signal (see arraySignal under Reactivity). Only for a custom collection type that wants each() to treat it as one.
  • DEV_HOOKS (kerfjs/dev) — the standard diagnostic hook bundle the dev entry installs, exported so a test suite can restore it with installDevHooks(DEV_HOOKS) after a production-shaped clearDevHooks() test (see the kerfjs/dev subpath under Stores).
import { html, type HtmlValue } from "kerfjs/html";
function html(strings: TemplateStringsArray, ...values: HtmlValue[]): SafeHtml;
type HtmlValue =
| SafeHtml
| string
| number
| boolean
| null
| undefined
| ReadonlySignal<unknown>
| readonly HtmlValue[];

Tagged-template authoring path — the same SafeHtml output and the same runtime semantics as JSX, with no JSX transform required. Made for CDN / importmap consumers (“no build step” taken literally); it lives at its own subpath so JSX-only apps don’t ship it.

html`<div class="${cls}">Count: ${count}</div>`;
html`<ul>
${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}
</ul>`;

Text/child holes follow the JSX child rules (escaping, number stringify, boolean/nullish → nothing, arrays, SafeHtml/each() passthrough, signal → fine-grained text binding, DOM nodes throw). Attribute holes follow the JSX attribute rules (boolean attributes, SafeHtml bypass, dangerous-URL screening, on*/malformed-name rejection, signal → fine-grained attribute binding). Two differences: attribute names are emitted verbatim (write class, not className — no camelCase aliasing), and holes are only legal in text positions or as a complete attribute value (attr=${v} / attr="${v}"); tag-name holes, attribute-name holes, partial values (class="a ${b}"), and holes inside comments throw. Static template parts pass through verbatim (same trust model as JSX tags/attrs). The static parts are parsed once per call site and cached by template-strings identity. See docs/6-jsx-runtime.md.

Custom-element typing via declaration merging

Section titled “Custom-element typing via declaration merging”

kerf’s per-tag intrinsic-element interfaces are aliased into the kerfjs/jsx-runtime JSX namespace. To add tags for custom elements / web components, declaration-merge into the kerfjs/jsx-runtime JSX namespace:

import type { KerfCustomElement } from "kerfjs/jsx-runtime";
declare module "kerfjs/jsx-runtime" {
namespace JSX {
interface IntrinsicElements {
"my-element": KerfCustomElement & { foo?: string };
}
}
}

IntrinsicElements is exported as an interface (not a type alias) precisely to make this pattern work — type aliases can’t be merged. KerfCustomElement, KerfBaseAttrs, AttrLike, AttrValue, and DataAriaAttrs are all re-exported from kerfjs/jsx-runtime so apps can compose attribute types without reaching into the internal kerfjs/jsx-types path.

Attribute names, value sets, and per-element membership come from the WHATWG HTML Living Standard and SVG 2 — not from another framework’s table, which models a property surface rather than the content attributes kerf actually emits. Coverage is focused rather than exhaustive; the deliberate departures (lowercase aliases, contentEditable="inherit", the @deprecated presentational attributes) are enumerated in the JSX type definitions. The rule that follows from it — boolean means boolean attribute, and HTML’s enumerated attributes take strings — is documented in docs/6-jsx-runtime.md.

AttrValue / AttrLike (attribute values) and the JSX child type accept a ReadonlySignal<unknown> in addition to the usual string | number | boolean | null | undefined | SafeHtml. Passing a signal/computed itself into a JSX attribute (class={someSignal}) or text hole ({someSignal}) inside a mount() binds that hole fine-grained — the node updates on signal change without re-running render() or walking the reconciler. ReadonlySignal is used (covariant) so both signal(...) and computed(...) of any T are accepted. Outside a mount() (SSR / .toString()) the signal snapshots its current value. Full semantics, the “use computed() not a bare closure” rule, and the row-mutation staleness limitation are in docs/2-reactivity.md.

URL-bearing attribute names are matched ASCII-case-insensitively, so author spellings such as HREF and Src cannot bypass the screen on either static or fine-grained bound paths.

Plain-string values passed to href, src, xlink:href, formaction, action, or data (<object data>) are screened by scheme (javascript: / vbscript: and script-executing data: document types). Matching values cause the attribute to be dropped entirely. In development the screen throws an Error with the diagnostic (fail loudly at your desk); in production it console.warns and drops (never crash a shipped app on attacker-influenced data) — the attribute is dropped in both modes, only the reporting differs, and production output is byte-identical to before. Mode follows whether kerfjs/dev is imported — kerf does not probe the environment. The javascript: no-op placeholders — javascript:void(0), javascript:void(0);, javascript:void 0, javascript:;, and a bare javascript: — are allowed: they are the placeholder-link idiom rather than an attack, and the match is against the whole normalized value so nothing can be appended to one. The screen is bypassed for SafeHtml (i.e. raw(...)) values in both modes — that’s the documented opt-out for legitimate cases (bookmarklet builders, sanitized-upstream URLs). Non-URL attributes are not screened. The same screen applies to fine-grained bound URL attributes (a href={sig} whose signal resolves to a dangerous value is dropped — throwing in dev, warning in prod). See docs/6-jsx-runtime.md for the full rationale and examples.

toElement(jsx: SafeHtml | string): Element | DocumentFragment

Section titled “toElement(jsx: SafeHtml | string): Element | DocumentFragment”

Parses a JSX/SafeHtml/string and returns a DOM node ready to insert into a parent.

  • Single-root input (one element child, surrounding whitespace OK) → returns the Element. For <svg> roots and orphan SVG fragments (<path>, <g>, <circle>, …) the input is XML-parsed through DOMParser('image/svg+xml') so the returned element is namespaced correctly and malformed SVG markup is rejected with a parse error.
  • Multi-root input (multiple elements, or any non-whitespace text alongside an element — <><svg/> label</>, <>icon<text>icon</>, two icons side by side) → returns a DocumentFragment containing every top-level node, including text nodes. Pass the result straight to parent.appendChild(...) / parent.replaceChildren(...) / parent.append(...) — the DOM insertion APIs inline a DocumentFragment’s children on insert and empty the fragment, so the caller never sees the wrapper. Nothing is silently dropped.

Throws if the input produces zero element children OR if DOMParser rejects an SVG input.

The returned node is always adopted into the live document (node.ownerDocument === document), never left owned by the inert <template> / DOMParser document it was parsed in. This matters when you operate on the node before inserting it — e.g. mount(toElement(<div/>), …), which sets innerHTML on first render. An inert-document element is unsafe to mutate that way on some engines (WebKit can mis-parse innerHTML on it under rapid bursts), so kerf moves the node into the live document up front. Identity and SVG/MathML namespaces are preserved by the adoption.

Security — trusted input only. toElement() parses its input into live DOM with no escaping and no URL screening — the same trust model as innerHTML / raw(). The SVG path is more dangerous than the HTML path: a top-level <svg><script>…</script></svg>, an SVG event attribute (onload, <animate onbegin>), xlink:href="javascript:", or <foreignObject> HTML all execute once the returned node is inserted into the live document — whereas an HTML-string <script> is inert (it’s parsed via <template>.innerHTML, which never runs scripts). Pass only markup you trust: built via JSX, or sanitized upstream with an SVG-aware sanitizer (DOMPurify). Never pass unsanitized user input.

Attribute Effect
id="..." Used as a diff key. Highest priority.
data-key="..." Used as a diff key. Lower priority than id.
data-morph-skip (any value, even empty) Element AND subtree preserved as-is on every re-render. No attribute morphing on the element itself.
data-morph-skip-children (any value, even empty) Attributes on the element morph normally; the subtree is left as-is. For client-hydrated slots whose host state classes still need to flow through.
data-morph-preserve (any value, even empty) The element is skipped by the diff’s trailing-removal pass — survives across renders even when the new template doesn’t emit it. For imperatively-injected nodes (autoplay video, tooltip overlays, analytics pixels). Does NOT block a keyed-match move.
Element kind Behavior when focused during a morph
<input type="text" | "search" | "url" | "email" | "tel" | "password" | ""> Live .value + selectionStart/selectionEnd copied to the morph target; morph proceeds (attribute updates apply).
<textarea> Same as text-entry inputs.
[contenteditable] Entire subtree skipped on this morph (same mechanism as data-morph-skip). User’s edit + caret + multi-range selection preserved verbatim; attribute updates deferred until the next render after blur. See docs/4-render.md.
Anything else (<button>, <a>, <div tabindex>, non-text inputs…) Morph proceeds normally — no special handling.
User-agent-owned attribute Effect
<details> open The morph never removes open from a live <details> — the user agent toggles it on summary click and the diff treats it as user-owned. Trade-off: controlled-style <details open={false}> won’t auto-collapse a previously-opened details element; drive .open imperatively if you need controlled behavior. See docs/4-render.md.
<dialog> open Same as <details>. The browser sets open="" when .show() / .showModal() is called; the morph leaves it alone.
Custom-element open Same as <details>, for any hyphenated tag (e.g. Web Awesome’s wa-select / wa-dropdown / wa-dialog, which reflect their live open state to open). An unrelated re-render no longer closes an open popup; a template can still set open, but closing is imperative.
Custom-element role / aria-* Web components set their own host ARIA (e.g. Web Awesome’s wa-option role="option" / aria-selected), so for any hyphenated tag the morph never removes role or aria-* the template omits. The template can still set or change them, and a signal binding set to null / false still removes them; plain elements are unchanged. Trade-off: omitting one from a static template leaves the last value — render an explicit value instead. See the render guide’s user-agent-owned attributes section.
Custom-element attributes the template never rendered Custom elements reflect their own defaults onto the host (Web Awesome’s wa-button variant / appearance / size, wa-tag, wa-badge attention, …). On any hyphenated tag the morph records which attributes the template rendered and removes an omitted one only when the previous template rendered it, so reflected defaults are not stripped (and re-added, forcing a component update) on every re-render. A template that stops rendering an attribute still removes it; an element kerf creates starts with its template’s record, so even its first re-render keeps reflected defaults; plain elements are unchanged. Trade-off: an attribute set imperatively on a custom element survives re-renders. See the render guide’s user-agent-owned attributes section.

Optional subpath (import { overlay, confirm, toast } from 'kerfjs/overlay') that blesses the modal/overlay pattern every real kerf app hand-rolls. Structural only — kerf ships no CSS; you style the wrapper. Each function owns its DOM + listeners in a closure and returns a handle (no per-instance framework state). Because overlay() owns its content’s mount() (so close() disposes it), this subpath pulls in the core renderer — but an app already importing kerfjs shares that via code-splitting, so the marginal cost is ~2 KB.

const dialog = overlay(<Settings />, { dismiss: ['escape', 'backdrop'], initialFocus: 'input' });
await dialog.result; // resolves when closed
dialog.close(value); // or close it yourself

Appends a wrapper (class from options.className, default 'kerf-overlay') to options.container (default document.body), mount()s content inside it, wires the requested dismissals, and returns an OverlayHandle { el, close(result?, options?), result }. content is an OverlayContent — a SafeHtml (static) or a () => MountResult render function (driven reactively). close() is idempotent: it disposes the mount, removes the listeners + node (after an opt-in exit; see exitClass below), restores focus to the previously-focused element, and resolves result. Concurrent overlays — plain-<div> fallbacks and native <dialog> / [popover] surfaces alike — arbitrate user input through one open-order stack: each Escape, backdrop click, outside click, or trapped Tab goes to the topmost surface that handles it, a modal surface blocks everything beneath it, and a surface that handles nothing (a tooltip(), or any overlay with dismiss: false and trap: false) never intercepts. So a tooltip shown inside a modal leaves the modal’s Escape and focus trap intact, a popover that dismisses on Escape gets it before the modal beneath, and one Escape, backdrop, or outside click closes exactly one surface (a click that is both outside a popover and on a modal’s backdrop closes only the popover). Closing a surface does not close surfaces stacked above it. When a lower surface closes first, focus stays in the surface that holds it, and a surface opened from inside the closed one later restores focus to the closed surface’s own opener instead of a detached node.

Options (OverlayOptions): dismiss ('escape' | 'backdrop' | 'outside', an array, or false; default ['escape', 'backdrop'] — 'backdrop' is a click on the wrapper itself, 'outside' a click anywhere outside it for popovers); initialFocus (selector, true = first focusable, or false; default true; see below); trap (trap Tab/Shift+Tab within + set role="dialog" / aria-modal; default true); role; onDismiss; outsideIgnore (elements whose clicks don’t count as outside, e.g. the trigger); native; presentation; and exitClass / exitDuration (all below). To keep trapped traversal consistent with Chromium and Firefox, kerf gives implicitly focusable descendants an explicit tabindex="0" (neutralizing WebKit/macOS’s system preference that otherwise skips some controls); an authored tabindex, including -1 or a positive value, is preserved. A custom element that cannot take focus yet — not defined, or (like a Web Awesome wa-button) not rendered, so its focus() does nothing or throws — does not fail the open: focus holds on the overlay, then moves to the element once it is defined and rendered, unless the overlay closed or focus moved in the meantime. A user dismissal resolves result with undefined.

Construction is transactional. A string initialFocus is parsed before anything is touched; a malformed selector throws an Error naming overlay() and the selector (the engine’s SyntaxError is kept as its cause) instead of a generic DOMException after setup. If a later setup phase throws — the content’s first render, or showModal() / showPopover() under native — overlay() rolls back everything it already installed (wrapper node, mount, listeners, its place in the concurrent-overlay stack, top-layer state, moved focus) and rethrows the original error, so a failed call leaves no orphaned overlay that could swallow Escape or outside clicks meant for another one. popover() and tooltip() extend the same guarantee to their positioning step, and confirm / prompt / form / choice inherit it and extend it over their own post-open wiring: if a required-slot check, the click-table delegate(), or the Enter-key listener fails after the overlay opened, the helper removes the listeners it already wired, closes the overlay, and rethrows the original error synchronously. A failed helper call therefore returns no promise at all — never a pending one attached to an overlay left on screen.

native (boolean, default false) — opt into the browser top layer. When true and the engine supports it, a modal overlay (trap: true) is hosted in a <dialog> opened with .showModal() — real document inerting + guaranteed stacking above any z-index — and a non-modal one (trap: false) uses the Popover API ([popover] + showPopover()). Feature-detected (HTMLDialogElement.prototype.showModal, HTMLElement.prototype.showPopover); falls back to today’s plain <div> where unsupported, so it is always safe to pass. The render slot + promise API are unchanged — kerf just hosts your markup in a <dialog> / [popover]. Two caveats: the native elements carry UA default styles (a ::backdrop, centering, border, padding) that kerf does not reset — style the element and its ::backdrop via className (.kerf-overlay::backdrop { … }); and container becomes a visual no-op (the top layer ignores DOM position). native is also accepted by confirm / prompt / form / choice / popover / tooltip. While a modal <dialog> is open, every surface takes the native path even without native, because a plain <div> would be inert and painted beneath the dialog: a tooltip or popover becomes a [popover], and a nested modal (a confirm() from inside the dialog) becomes a <dialog> of its own. The same happens to tooltips and popovers already showing when kerf opens a modal <dialog> (a native: true modal, or a lifted one): they move into the top layer, or re-show there, so they stay above the new dialog. So does a modal <dialog> the app opens itself, outside kerf: while any kerf surface is open, one capture-phase toggle listener on the document notices the dialog opening and re-hosts the surfaces above it (one task after showModal(), since the event is queued; an engine that fires no toggle for <dialog> leaves such a dialog unobserved). A modal <dialog> inside an open shadow root (Web Awesome’s wa-dialog / wa-drawer) counts for both: kerf also looks for one in each open shadow root when a surface opens, and watches those roots for toggle while a surface is open (a closed shadow root is invisible). Browsers keep a popover outside the modal dialog inert even in the top layer, so a lifted popover is visible but its controls cannot be used — which is why a popover() / tooltip() whose anchor sits inside an open modal <dialog> is not lifted at all but rendered into the dialog’s host slot: its [data-kerf-overlay-host][data-morph-skip] element, so it is part of the modal subtree and fully clickable and focusable (still position: fixed, still anchored). A <dialog> kerf opened (a native: true modal, or a lifted one) gets that slot automatically — an empty display: contents <div data-kerf-overlay-host data-morph-skip data-morph-preserve> appended on first use, which adds no layout box and survives the dialog content’s re-renders. An app-owned dialog opts in by including <div data-kerf-overlay-host data-morph-skip></div> in its markup (both attributes are required; the slot is the one place a second mount() may live inside a mounted tree — see mount()), or passes container explicitly. The always-on kerfjs/dev warning reports a lifted popover with controls (no slot was available) and an engine without the needed API.

presentation (an OverlayPresentation: 'modal' | 'bottom-sheet', default 'modal') — present the overlay as a bottom sheet attached to the bottom edge, the way a phone shows a compose or quick-edit sheet; it mirrors @kerfjs/ui’s DialogSurface presentation="bottom-sheet". kerf still ships no CSS and sets no geometry. 'bottom-sheet' does two things: it marks the wrapper data-presentation="bottom-sheet" (the plain <div> and the native <dialog> / [popover] host alike), and while the overlay is open it follows visualViewport (resize + scroll) to publish the virtual keyboard’s overlap as the inline custom property --kerf-keyboard-inset: <px> on the wrapper — the layout viewport’s height minus the visual viewport’s height and top offset, rounded; removed when there is no overlap, while pinch-zoomed, and on close (an engine without visualViewport publishes nothing; a page whose viewport meta sets interactive-widget=resizes-content shrinks the layout viewport instead, so the overlap stays zero). It is published before showModal(), so the first frame already clears a keyboard that is up. Your CSS supplies the rest, keyed on the attribute: rest the panel on the bottom edge, square its bottom corners, and pad its block end by calc(16px + max(env(safe-area-inset-bottom), var(--kerf-keyboard-inset, 0px))) (your own gutter plus whichever is larger) so its content clears the home indicator and the keyboard. On the plain path the wrapper is the full-screen backdrop and its content is the panel; on the native path the <dialog> is the panel (override the UA centering with margin-block-end: 0) and ::backdrop is the backdrop. Motion is yours too: an entrance can slide up with @starting-style or a keyframe, and an exit can slide down through exitClass / exitDuration (below). presentation is also accepted by confirm / prompt / form / choice, and with trap: false.

/* Plain path: the wrapper is the backdrop; its content is the sheet. */
div.kerf-overlay[data-presentation="bottom-sheet"] {
position: fixed;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
}
div.kerf-overlay[data-presentation="bottom-sheet"] > * {
inline-size: min(100%, 40rem);
border-radius: 12px 12px 0 0;
padding-block-end: calc(16px + max(env(safe-area-inset-bottom), var(--kerf-keyboard-inset, 0px)));
}
/* native: true — the <dialog> is the sheet. */
dialog.kerf-overlay[data-presentation="bottom-sheet"] {
inline-size: min(100%, 40rem);
max-inline-size: 100%;
margin-block-end: 0;
border-radius: 12px 12px 0 0;
padding-block-end: calc(16px + max(env(safe-area-inset-bottom), var(--kerf-keyboard-inset, 0px)));
}

exitClass (string) / exitDuration (number, ms, default 0) — let CSS animate the overlay out. They mirror toast()’s pair, and an exit runs when exitClass is set or exitDuration > 0. On close() everything observable still happens at once:

  • result settles, so a promise dialog resolves right away;
  • the overlay leaves the concurrent-overlay stack, so one opened during the exit is topmost and owns Escape;
  • the listeners and the mount are disposed;
  • a native host leaves the top layer, so the page is interactive again;
  • the wrapper turns inert, so clicks reach the page beneath and it is hidden from assistive tech;
  • focus returns to the opener;
  • exitClass is added.

Only the node’s removal waits, until whichever comes first: the CSS animations / transitions then running in the wrapper finish (finite ones, read through getAnimations({ subtree: true }), so the removal follows the animation’s real end), or exitDuration ms pass (the hard cap). When nothing is animating, it goes at once. As with toast(), exitClass with exitDuration: 0 removes on the next tick, so set the cap to at least the animation’s length. prefers-reduced-motion: reduce skips the exit entirely.

close() stays idempotent. A second call during the exit does nothing and the first value wins. close(result, { instant: true }) (an OverlayCloseOptions) skips the exit, or ends one already running.

A surface that lives inside the closing overlay leaves at the same close() rather than when the exiting node is finally removed: a popover() / tooltip() whose anchor is inside the overlay, and a surface rendered into it (for example in its host slot). It closes as cleanup (no onDismiss) and follows the host. With an exiting host it plays its own exit, if it has one. With a host that goes at once, it goes at once too.

A closed native <dialog> / [popover] is display: none and loses its ::backdrop, so to animate one, have exitClass keep it painted with the same fixed geometry it had while open. Alternatively, use transition: display <d> allow-discrete, overlay <d> allow-discrete with exitDuration alone where the engine supports overlay. exitClass / exitDuration are also accepted by confirm / prompt / form / choice, and by popover / tooltip (see each below).

@keyframes sheet-out { to { transform: translateY(100%); } }
@keyframes fade-out { to { opacity: 0; } }
/* Plain path: fade the backdrop, slide the sheet. */
div.kerf-overlay.is-leaving { animation: fade-out 200ms ease-in forwards; }
div.kerf-overlay.is-leaving > * { animation: sheet-out 200ms ease-in forwards; }
/* native: true — keep the closed <dialog> painted for the exit. */
dialog.kerf-overlay.is-leaving {
display: block;
position: fixed;
inset: auto 0 0;
animation: sheet-out 200ms ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.kerf-overlay.is-leaving, .kerf-overlay.is-leaving > * { animation: none; }
}
const sheet = overlay(<Compose />, { presentation: 'bottom-sheet', exitClass: 'is-leaving', exitDuration: 300 });

confirm(message, options?): Promise<boolean>

Section titled “confirm(message, options?): Promise<boolean>”
if (await confirm("Delete this file?", { danger: true })) remove();

A promise-based window.confirm replacement (that global is a no-op in Tauri WKWebViews). Renders a two-button dialog on top of overlay() and resolves true for OK, false for Cancel or any dismissal. message + labels are auto-escaped through the JSX runtime. Options (ConfirmOptions): title, okText (default 'OK'), cancelText (default 'Cancel'), danger (adds a kerf-confirm--danger class), plus container / className, native, presentation ('bottom-sheet' presents it as a bottom sheet; see overlay()), and exitClass / exitDuration (an exit motion; the promise settles at once and only the node’s removal waits).

prompt(message, options?): Promise<string | null>

Section titled “prompt(message, options?): Promise<string | null>”
const name = await prompt("Rename layer", { defaultValue: layer.name });
if (name !== null) rename(name);

The symmetric sibling of confirm() — a promise-based window.prompt replacement (also a no-op in Tauri webviews). Renders a one-field dialog on top of overlay() and resolves the entered string on OK (an empty string is a valid result), or null on Cancel / dismissal. Enter in the input submits. message, the default value, and labels are auto-escaped. Options (PromptOptions): defaultValue (default ''), placeholder, inputType (default 'text'), title, okText / cancelText, validate (a synchronous FieldValidator: return a non-empty error string to block OK — it shows inline), plus container / className, native, presentation ('bottom-sheet' presents it as a bottom sheet; see overlay()), and exitClass / exitDuration (an exit motion; the promise settles at once and only the node’s removal waits). A validate that throws is treated as a programming error: the dialog closes (full teardown, focus restored) and the returned promise rejects with that error, so the awaiting caller sees it — nothing is thrown out of the OK click or Enter handler. Validators are synchronous: one that returns a promise is the same kind of programming error and rejects with a TypeError (the stray promise’s own rejection is silenced). The input is looked up again on every OK, so a reactive render that replaces it reads the live element; if a re-render removed it, OK rejects with prompt(): removed after open: <input data-prompt-input>.

form(fields, options?): Promise<Record<string, string> | null>

Section titled “form(fields, options?): Promise<Record<string, string> | null>”
const creds = await form([
{ name: "host", label: "Host", defaultValue: "localhost" },
{
name: "token",
label: "API token",
type: "password",
validate: (v) => (v ? "" : "required"),
},
]);
if (creds !== null) connect(creds.host, creds.token);

The two-or-three-input generalization of prompt(): renders one labeled input per FormField and resolves a Record<name, value> on OK (after every field’s validate passes) or null on Cancel / dismissal. Enter in any field submits; the first invalid field is focused. Each FormField has name (the record key + input name), optional label (defaults to name), defaultValue, placeholder, type (default 'text'), and validate (synchronous, as in prompt(); a throwing validate closes the dialog and rejects the returned promise with that error). A field input removed after the dialog opened (for example by a reactive re-render of a render slot) is treated the same way: OK or Enter closes the dialog and rejects the promise with an error naming the missing field, rather than resolving null (which would read as a Cancel). Options (FormOptions): title, okText / cancelText, container / className, native, presentation ('bottom-sheet' presents it as a bottom sheet; see overlay()), and exitClass / exitDuration (an exit motion; the promise settles at once and only the node’s removal waits). As with prompt(), a field.validate that returns a promise rejects with a TypeError.

choice<R>(message, actions, options?): Promise<R | null>

Section titled “choice<R>(message, actions, options?): Promise<R | null>”
const r = await choice(
"Unsaved changes",
[
{ value: "save", label: "Save Draft" },
{ value: "discard", label: "Discard", className: "btn-danger" },
{ value: "cancel", label: "Keep Editing" },
],
{ defaultValue: "cancel" },
);
// r is 'save' | 'discard' | 'cancel' | null (Escape/backdrop)

The N-way sibling of confirm(): renders one button per ChoiceAction<R> and resolves that action’s value on click, or null on Cancel / dismissal. Pass defaultValue to make Enter (pressed anywhere in the dialog) resolve a default action — the “global Enter-to-confirm” model — without holding the overlay handle. An action’s value may itself be null/undefined and stays distinct from a dismissal (the promise resolves on the click, not via the overlay’s result). message + labels auto-escape; render gives BYO markup (spread each slots.actions[i] onto your buttons). kerf owns dismiss / focus-trap / focus-restore. Options (ChoiceOptions<R>): title, defaultValue, render, container / className, native, presentation ('bottom-sheet' presents it as a bottom sheet; see overlay()), and exitClass / exitDuration (an exit motion; the promise settles at once and only the node’s removal waits). For fully bespoke keyboard/close control, drive overlay() directly.

Bring your own markup (render) — design-system dialogs

Section titled “Bring your own markup (render) — design-system dialogs”

confirm / prompt / form render kerf’s own button/field DOM with kerf class names. When you have a design system and want its markup + classes, pass a render option: it returns the full dialog body, and you spread the provided wiring slots onto your own elements. kerf keeps owning the promise, validate, Enter-submit, dismiss, focus-trap, and focus-restore — you only own the look. (The generic overlay() is the other route: mount any markup and drive dismiss/focus yourself.)

// confirm with your design system's buttons — { message, ok, cancel } are attribute bags
await confirm("Delete this file?", {
render: ({ message, ok, cancel }) => (
<div class="modal">
<p>{message}</p>
<button {...cancel} class="btn btn-sm">
No
</button>
<button {...ok} class="btn btn-danger">
Yes
</button>
</div>
),
});

prompt’s slots are { message, input, error, ok, cancel } (spread input onto your <input>, error optional); form’s are { fields, ok, cancel } where each fields[i] is { name, label, input, error }. The prompt <input data-prompt-input> and one <input data-field="name"> for every form field are required: kerf validates them immediately after mounting, removes incomplete markup, and throws a descriptive error when one is missing. Omitting an error slot just skips that inline message — validate still blocks and focuses. Slot types: ConfirmRenderSlots, PromptRenderSlots, FormRenderSlots / FormRenderField.

popover(anchor, content, options?): OverlayHandle

Section titled “popover(anchor, content, options?): OverlayHandle”
const trigger = document.querySelector('#menu-btn')!;
const pop = popover(trigger, <Menu />); // opens below the button, dismisses on outside click

An anchored, non-modal overlay: positions content relative to anchor (below by default, flipping above if it would overflow the viewport, and clamped horizontally) and repositions on scroll / resize, and when the anchor itself resizes, while open. It’s a thin wrapper over overlay() with non-modal defaults — trap: false, dismiss: ['outside'], and the anchor added to outsideIgnore so the click that opened it doesn’t immediately close it. Returns the same OverlayHandle; close() also drops the reposition listeners. The popover closes itself (resolving result with undefined, without calling onDismiss) when anchor leaves the document, but not when the anchor merely moves within the document. An overlay holding the anchor closes it at that overlay’s own close(), even while the overlay’s node lingers for an exit. A popover rendered into a modal dialog’s host slot likewise closes when the slot leaves the document, even if its anchor was moved out of the dialog first. A popover inside an open modal dialog (in its host slot, or an explicit container there) also closes when that dialog closes. position: fixed is set inline (you style everything else — kerf ships no CSS). Options (PopoverOptions): placement ('bottom' | 'top', default 'bottom', auto-flips), align ('start' | 'end', default 'start'), gap (px, default 4), viewportMargin (px gutter from the viewport edges, default 0), fitHeight (default false; see positionAnchored below), dismiss, initialFocus (default false), outsideIgnore (merged with the anchor), onDismiss, container (default: when anchor sits inside an open modal <dialog>, that dialog’s [data-kerf-overlay-host][data-morph-skip] slot — created automatically for a dialog kerf opened, and in the light DOM of a shadow host whose modal <dialog> lives in its shadow root with anchor slotted into it, such as Web Awesome’s wa-dialog / wa-drawer — so the popover stays interactive, with each implicitly tabbable control given an explicit tabindex="0" so Tab reaches it in every engine, macOS WebKit included; otherwise document.body) / className (default 'kerf-popover'), and native (host in the top layer via the Popover API where supported — [popover] + showPopover() — else a plain <div>; kerf keeps owning positioning + dismiss). The positioning is dependency-free (below/above + clamp); for complex cases (arrow, collision on both axes) drive overlay() yourself.

exitClass / exitDuration — the popover’s exit motion, forwarded to overlay() (see overlay above for the full contract). close() and a user dismissal (an outside click) play it: result settles, repositioning stops, and the wrapper turns inert with exitClass added while only the node’s removal waits. The teardown when anchor leaves the document is instant, since there is no anchor left to exit from, and so is a rollback when positioning throws at construction. close(result, { instant: true }) skips or ends the exit.

positionAnchored(el, anchor, options?): void / autoReposition(el, anchor, options?): () => void

Section titled “positionAnchored(el, anchor, options?): void / autoReposition(el, anchor, options?): () => void”
positionAnchored(hintEl, badgeEl, { placement: "top" }); // one-shot
const stop = autoReposition(hintEl, badgeEl, { gap: 6 }); // stays glued; stop() to unbind

popover()’s placement core, exported for positioning your own element against an anchor with no overlay lifecycle (an inline hint, a floating label). positionAnchored sets el.style position: fixed, margin: 0, left, top — below the anchor by default, flipping above on overflow, aligned to a horizontal edge and clamped into view. left/top are computed in viewport coordinates and then mapped into el’s fixed-position containing block, so placement stays correct when an ancestor with a transform, filter, perspective, contain, or will-change captures fixed descendants — a transformed modal <dialog> holding an overlay host slot, for example. kerf measures that containing block with a 1px probe inserted beside el and removed synchronously, and compensates for its translation and scale (not rotation or skew). autoReposition positions once, then re-runs on scroll (capture — catches inner scroll containers), resize, and, where ResizeObserver exists, whenever the anchor’s or el’s own size changes (a field that grows a line as it fills, or an end-aligned or above-placed surface whose content grows), returning a disposer that removes the listeners and the observer. A size change the positioning itself caused (a published fitHeight cap, a shrink-to-fit width near the viewport edge) is not acted on again, so repositioning never feeds itself. Both take AnchorPositionOptions (placement, align, gap, viewportMargin, fitHeight). viewportMargin (px, default 0) keeps a gutter between el and the viewport edges: the horizontal clamp stops that far inside the left and right edges, and a side fits only when el ends that far inside the top or bottom edge. fitHeight (default false) is for an element that scrolls its overflow: when neither side has room for its full height it takes the side with more room, and on every placement it publishes that room (from the gap to the viewport margin, in el’s CSS px) as the --kerf-anchor-available-height custom property on el, so CSS can cap it — max-height: var(--kerf-anchor-available-height); overflow: auto. kerf reads the element’s full height from its scroll metrics rather than lifting the cap, so scrolling inside it never resets its scroll position. An above placement hangs from its bottom edge: kerf sets bottom (with top: auto, the containing block’s bottom edge measured with the same kind of probe) instead of deriving top from a height measured during the reposition, so whatever height el goes on to lay out (its cap from the room just published, a max-height expand still running, content that grows) it grows and shrinks upward, away from the anchor, never over it; a below placement sets top with bottom: auto. That full height is limited by el’s own max-height, read with the published property briefly swapped for an unbounded room and el’s current height pinned for that read (an engine that lays el out to resolve a container query inside it, as WebKit does, keeps the same box, so the scroll position survives; one the read still moves is put back), so an app cap such as max-height: min(300px, var(--kerf-anchor-available-height)) counts as 300px and an element that fits the preferred side at its cap stays there; a relative cap (60%, calc(), min(), clamp()) is resolved against el’s fixed-position containing block (normally the viewport) with an unpainted probe beside el, a keyword cap (none, fit-content) is no cap, and a max-height transition on el is held off for that read, so it neither reports its running value nor starts (every other transition keeps running), while an animation or transition already driving max-height (an expand-from-0 entrance, a @keyframes animation) is measured at the value it is headed to — its last max-height keyframe, or its first while the current iteration plays in reverse — and left running untouched (a keyframe var() kerf cannot resolve outside el is no cap). kerf still ships no CSS: without such a rule the property changes nothing.

tooltip(anchor, content, options?): () => void

Section titled “tooltip(anchor, content, options?): () => void”
const stop = tooltip(buttonEl, "Delete this item"); // hover/focus tooltip, above by default

A hover/focus-triggered, non-modal, auto-hiding tooltip anchored to anchor. Shows after delay on pointerenter/focus, hides after hideDelay once both pointer and focus have left, and keeps itself positioned with autoReposition (placement defaults to 'top'). Pointer and focus presence are tracked independently, so leaving one modality does not hide a tooltip that is still active through the other. No click-dismiss model — it follows the pointer/focus. It also hides as soon as anchor leaves the document, since a removed anchor fires no pointerleave / blur, and as soon as an overlay holding the anchor closes (even one still exiting; a show pending on an anchor inside an inert subtree never appears); moving the anchor within the document keeps it shown, and a show still pending when the anchor leaves never appears. A tooltip shown in a modal dialog’s host slot hides when the slot leaves the document too (even with its anchor moved out of the dialog), and the next hover or focus shows it again. content is a TooltipContent (a string is auto-escaped, or pass SafeHtml / a render fn). Returns a disposer that removes the anchor listeners and hides any shown tooltip. Options (TooltipOptions, extends AnchorPositionOptions): delay (default 400), hideDelay (default 100), role (default 'tooltip'), container (default: the anchor’s modal-dialog host slot, as for popover(), else document.body) / className (default 'kerf-tooltip').

exitClass / exitDuration — the tooltip’s exit motion (the overlay contract). The hide after hideDelay plays it: the wrapper turns inert with exitClass added, and the node goes when the exit’s animations finish or exitDuration passes. Hiding because anchor left the document, a failed show’s rollback, and the disposer are instant (the disposer also ends an exit already running). A tooltip shown again while the previous one is still exiting removes the leaving node at once and opens a fresh one in the same task, so an anchor never has two tooltip nodes and no frame paints without one. Reviving the leaving node is not attempted: its mount and listeners were already disposed at the hide.

A failed show is reported, not retried. The tooltip opens from its delay timer, so no caller is on the stack to receive an error. If opening throws — a render-function content throws, positioning (autoReposition) fails, or native showPopover() fails — the half-built tooltip is rolled back first (nothing stays on screen or subscribed), and the original error then escapes the timer callback for the host to report as an uncaught exception: in a browser, a window error event plus a console entry; under fake timers, a throw from the timer-advancing call. This matches how kerf surfaces errors from its other deferred callbacks (debounce / throttle timers, attach teardown), and there is no onError option. The tooltip stays armed: it does not retry while the pointer or focus simply stays on the anchor, and the next pointerenter / focus schedules a fresh attempt.

toast("Saved", { variant: "success" });
const { el, dismiss } = toast("Uploading…", { duration: 0 }); // sticky; dismiss() when done
toast("Only the latest shows", { mode: "replace" }); // collapse-to-latest

Shows a non-modal, auto-dismissing notification, stacked in a shared body-level region — a <div class="kerf-toasts" aria-live="polite"> appended to document.body on first use; an existing .kerf-toasts element anywhere in the document is reused instead, so you can place (and style) the region yourself — or in options.container. content is a ToastContent: plain strings are escaped and rendered as text, while SafeHtml and render functions are the explicit trusted-markup paths. Returns a ToastHandle { el, dismiss } — el is the node (inspect it, wire an action button, or run your own entrance/exit transitions) and dismiss() removes it early (idempotent). dismiss({ instant: true }) removes it synchronously, skipping the exit transition — for an action button that immediately shows a replacement toast in a single centered slot (no cross-fade); mode: 'replace' with collapse: 'instant' likewise cleans up a toast that is already mid-fade. Options (ToastOptions): duration (ms; 0 = sticky; default 4000), mode ('stack' default, or 'replace' — dismiss the region’s current toast(s) first for collapse-to-latest), collapse (how 'replace' drops the prior toast(s): 'fade' default = run their exit transition, good for a stacking region; 'instant' = remove them synchronously, what a single centered slot wants so messages never cross-fade in the same spot), variant ('info' | 'success' | 'warning' → adds a ${className}--${variant} accent class), enterClass (added on the next animation frame, so a CSS entrance transition runs), exitClass + exitDuration (CSS owns the exit: on dismiss the enterClass is REMOVED — so exitClass needn’t out-specify it, and a symmetric single-class fade works by setting only enterClass + exitDuration — then the node is removed exitDuration ms later, delayed whenever exitClass is set OR exitDuration > 0), className (default 'kerf-toast'), role (default 'status'), container.

OverlayHandle, OverlayContent, OverlayOptions, OverlayPresentation, OverlayCloseOptions, DismissTrigger, ConfirmOptions, ConfirmRenderSlots, PromptOptions, PromptRenderSlots, FormField, FormOptions, FormRenderSlots, FormRenderField, FieldValidator, ChoiceAction, ChoiceOptions, ChoiceRenderSlots, PopoverOptions, PopoverPlacement, AnchorPositionOptions, TooltipContent, TooltipOptions, ToastContent, ToastOptions, ToastVariant, and ToastHandle are all exported from kerfjs/overlay for annotating handles, content, and option bags.

Optional subpath (import { createScope, disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope') that groups disposers. createScope() owns a reusable group without an element; disposeScope(el) ties one to a DOM element’s lifetime. kerf hands out disposers (mount() / effect() / delegate() all return () => void), but nothing scopes them to a subtree, so append-heavy UIs leak detached-but-subscribed effects and listeners. Element scopes live in a WeakMap; standalone scopes keep their state in a closure.

const scope = createScope();
window.addEventListener('resize', update, { signal: scope.signal });
scope.add(() => observer.disconnect());
// …later:
const teardown = scope.dispose; // safe to store or return without a wrapper
teardown(); // runs registered disposers newest first, then aborts the signal
// The same handle can now collect another generation of listeners.

add(dispose) returns the disposer. size counts registered disposers in the current generation. dispose() is bound and idempotent for that generation; it keeps running after a disposer throws, then aborts the generation’s signal, then reports each thrown error through reportError (a microtask rethrow where reportError is missing) — never rethrown to the caller, but never silent. The next read of signal returns a fresh, un-aborted signal. New registrations made during cleanup belong to the next generation. Reading signal starts that generation even if no disposer is added, so native listeners registered with the signal alone are removed by the next dispose().

const s = disposeScope(card);
s.mount(card, renderCard); // mounts AND registers the disposer
s.effect(() => sync(card));
s.delegate(card, "click", ".del", del);
s.add(() => observer.disconnect()); // any () => void disposer
// …later:
s.dispose(); // runs them all, best-effort, idempotent

Returns the per-element Scope. Calling disposeScope(el) again for the same element returns the same scope (so disparate code paths register into one place); after dispose(), a later call starts fresh. Scope has add(dispose) (register any disposer, returns it), the convenience wrappers mount(el, render) / effect(fn) / delegate(root, type, selector, handler, options?) (which call the kerf primitive and register its disposer), and dispose() (runs every registered disposer best-effort — a throwing one won’t strand the rest, and its error is reported through reportError once the loop finishes rather than swallowed — then resets; idempotent). The disposed handle is then dead: a later add / mount / effect / delegate on it — including one made by a disposer while dispose() runs — tears down at once (add(fn) calls fn immediately; the wrappers create and immediately dispose), so nothing leaks into a scope that will never run again.

disposeSubtree(feed); // dispose every scope in feed, root included
feed.remove();

Disposes root’s own scope and every descendant scope, then leaves the DOM removal to you. Call it right before removing a subtree. Finds scopes by walking the subtree against the WeakMap — it adds no marker attributes to your DOM.

const stop = observeRemovals(document.body); // auto-dispose on removal, app-wide

Installs a MutationObserver on root that auto-runs a node’s scope (via disposeSubtree) when that node — or an ancestor — is permanently removed from the subtree. One observer covers the whole tree. A node moved between descendants of root, or reordered under the same parent, remains live because containment is checked when the asynchronous observer callback runs. Returns a disconnect function.

Containment is judged against this observer’s own root only. A node moved out of root counts as removed and is disposed even when it lands elsewhere in the document, including inside another observed root that contains this one: with observeRemovals(outer) and observeRemovals(inner) both active, moving a scoped node from inner to a sibling of inner disposes it. One observer on the outermost root you move nodes within is enough; a nested observer adds nothing except this per-root disposal. To relocate a scoped node outside its observed root (a portal, for example), observe a root that contains both positions.

The handle returned by disposeScope, exported for annotation: { add, mount, effect, delegate, dispose } (see disposeScope above). DisposerScope is the separate return type of createScope: { add, dispose, signal, size }.

Optional subpath (import { resource } from 'kerfjs/async') that models async state — the { status, data, error } shape every app reproduces — with the stale-response guard built in. You still write the fetch; .run() owns the status transitions and drops out-of-order responses. Signals only, no render core, so it’s tiny.

resource<T, I = void>(options?): Resource<T, I>

Section titled “resource<T, I = void>(options?): Resource<T, I>”
const users = resource<User[]>();
// Browser (client-side fetch):
users.run(() => fetch("/api/users", { headers: auth() }).then((r) => r.json()));
// SSR (Node 18+ global fetch — same primitive):
await users.run(() => fetch(apiUrl).then((r) => r.json() as Promise<User[]>));
// render off users.value.status
users.value.status; // 'idle' | 'running' | 'completed' | 'failed'

Returns a Resource<T, I>. resource.value is a tracking read of ResourceState<T, I> ({ status, data, error, progress, input, revision }) — drive UI off value.status, exactly like reading a signal inside mount()/computed()/effect(). run() itself reads the resource’s state untracked, so an effect() that reads an input signal and calls run() re-runs on each input change and does not subscribe to the state the run writes. Methods:

  • run(fetcher): Promise<T | undefined> — sets running, then completed (with data) or failed (with error), guarding against stale responses: only the latest run may resolve the state, so a slow response can’t clobber a newer one. It never rejects, whether the fetcher throws synchronously or its promise rejects — a failure lands in value.error; the returned promise resolves the data (or undefined on failure) for callers who want to await. Previous data is preserved across a re-run and on failure (stale-while-revalidate).
  • run(input, fetcher): Promise<T | undefined> — same as above, plus records input as value.input for the running/completed/failed states of this run (again latest-wins under the stale guard). Use it when the failure UI must know which request failed — e.g. an inline error keyed by value.input.fileId — so you don’t reintroduce module-scope bookkeeping. Parametrize the input type via the second type argument (resource<Diff, { fileId: string }>()).
  • reset(): void — back to idle, clearing data/error/progress/input and the per-key cache, and invalidating any in-flight run.
  • cached(key): T | undefined / cachedKeys(): string[] / clearCache(key?): void — a read-only view of the cacheKey cache, plus eviction. cached(key) returns a slice without running it (so a test or app can ask “is this window cached?”); cachedKeys() lists the cached keys (.length is the size); clearCache(key) evicts one key (or the whole cache with no argument) without touching value. Eviction applies to what is cached now: a run already in flight for an evicted key still writes its result to the cache when it lands as the latest run. A run superseded by a newer one (or invalidated by reset()) never writes the cache, even if its response succeeds.

Per-input cache + SWR (ResourceOptions): pass cacheKey(input) to keep the last value per key. Starting a run for a previously-loaded key paints its cached slice immediately (still running) while it revalidates; a never-loaded key starts with no data. Without cacheKey, a run keeps the previous run’s data (single-slot stale-while-revalidate), as before. cacheKey applies only to the two-argument run(input, fetcher): on a resource that has a cacheKey, a no-input run(fetcher) has no key, so it clears data while running and never writes to the cache.

Paint dedup (value.revision): a counter that bumps only when data actually changes — by options.equals (default Object.is). Compare it to the revision you last painted to skip a redundant re-render (a poll returning identical data leaves it untouched, so you don’t wipe scroll / sort / hover). Pass a structural equals to dedup a fresh-but-equal object.

const win = resource<Slice, string>({
cacheKey: (w) => w,
equals: (a, b) => a.etag === b.etag,
});
win.run(tab, () => fetchSlice(tab)); // revisiting a loaded tab paints instantly, then revalidates
effect(() => {
if (win.value.revision === lastPainted) return; // identical data → keep the DOM
lastPainted = win.value.revision;
paint(win.value.data);
});

Progress is opt-in: your fetcher receives a report(completed, total) callback (a plain () => Promise<T> is assignable — ignore it if unused). Reports from a superseded run are dropped.

Cancellation is opt-in too: the fetcher’s second argument is an AbortSignal that aborts when a newer run() supersedes this one or when reset() is called. It never aborts once its run has settled (resolved, rejected, or thrown), so a finished run is never cancelled after the fact. Hand it to fetch() to close the superseded request; the resulting AbortError rejection belongs to a superseded run, so the stale guard drops it and the resource never moves to failed because of it.

search.run(q, (_report, signal) =>
fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal }).then((r) => r.json()),
);
upload.run((report) =>
putWithProgress(file, (sent, size) => report(sent, size)),
);
// upload.value.progress -> { completed, total } | undefined
// input threading — recover the failed request in the error branch:
const diff = resource<Diff, { fileId: string }>();
diff.run({ fileId }, (report) => fetchDiff(fileId, report));
// on failure: diff.value.status === 'failed' && diff.value.input?.fileId

Resource<T, I>, ResourceState<T, I>, ResourceOptions<T, I>, ResourceStatus, ResourceProgress, and ResourceFetcher<T> are all exported from kerfjs/async. The input type I defaults to void (so resource<T>() keeps value.input as undefined).

kerf renders and diffs on the main thread, and it should: render functions read signals there, and parsing HTML and touching the DOM can only happen there. What a Worker can take off the main thread is your app’s own compute: fuzzy search over thousands of rows, parsing a CSV or markdown file, aggregating a dataset. resource() is the seam. Its fetcher is any function that returns a promise, so it can post a message to a Worker and resolve with the reply, and the result renders on the main thread like any other data.

Keep the data in the worker. Sending the whole dataset with every request blocks the main thread about as long as doing the work there, because structured clone is linear in the data. Load the data into the worker once and send only the query and the result.

A small client on the main thread owns one worker: a request/reply call(), cancellation, error handling, and disposal.

// compute-client.ts — main thread
type Reply = { id: number; result?: unknown; error?: string };
type Settle = { resolve: (result: unknown) => void; reject: (error: unknown) => void };
type Post = (message: Record<string, unknown>, transfer?: Transferable[]) => void;
export interface Compute {
/** Post one request and resolve with its reply. Aborting `signal` cancels it. */
call<T>(
message: Record<string, unknown>,
options?: { transfer?: Transferable[]; signal?: AbortSignal },
): Promise<T>;
/** Terminate the worker; every pending and later call rejects. */
dispose(): void;
}
/**
* One module worker behind a request/reply `call()`. `init` runs on every
* worker this starts, before any call reaches it: send the worker-resident
* data there (or tell the worker where to load it), so a worker respawned
* after a cancel starts from the same state.
*/
export function createCompute(
spawn: () => Worker,
init: (post: Post) => void = () => {},
): Compute {
const pending = new Map<number, Settle>();
let worker: Worker | undefined;
let failure: Error | undefined;
let nextId = 0;
function rejectAll(error: unknown) {
for (const settle of pending.values()) settle.reject(error);
pending.clear();
}
/** A worker that fails to load or crashes rejects every pending call, and the next ones. */
function fail(message: string) {
failure = new Error(message);
worker?.terminate();
worker = undefined;
rejectAll(failure);
}
function start(): Worker {
const next = spawn();
next.onmessage = (event: MessageEvent<Reply>) => {
const settle = pending.get(event.data.id);
if (settle === undefined) return;
pending.delete(event.data.id);
if (event.data.error === undefined) settle.resolve(event.data.result);
else settle.reject(new Error(event.data.error));
};
next.onerror = (event) => {
event.preventDefault();
fail(event.message || 'The compute worker failed to load or crashed');
};
next.onmessageerror = () => fail('A compute worker reply could not be read');
init((message, transfer = []) => next.postMessage(message, { transfer }));
return next;
}
/**
* A worker runs one message at a time and never sees an abort, so the only
* way to stop a running job is to terminate the worker. That discards every
* job on it; the next call starts a fresh worker and replays `init`.
*/
function cancel(reason: unknown) {
worker?.terminate();
worker = undefined;
rejectAll(reason);
}
return {
call<T>(
message: Record<string, unknown>,
{ transfer = [], signal }: { transfer?: Transferable[]; signal?: AbortSignal } = {},
): Promise<T> {
if (failure !== undefined) return Promise.reject(failure);
if (signal?.aborted) return Promise.reject(signal.reason);
worker ??= start();
const id = nextId++;
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
if (pending.has(id)) cancel(signal!.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
pending.set(id, {
resolve: (result) => {
signal?.removeEventListener('abort', onAbort);
resolve(result as T);
},
reject: (error) => {
signal?.removeEventListener('abort', onAbort);
reject(error);
},
});
worker!.postMessage({ ...message, id }, { transfer });
});
},
dispose() {
fail('The compute worker was disposed');
},
};
}

The worker holds the data and does the work. It needs no kerf import:

// compute.worker.ts — runs off the main thread
type Row = { id: number; label: string };
type Job =
| { kind: 'init'; url: string }
| { id: number; kind: 'search'; query: string }
| { id: number; kind: 'csv'; buffer: ArrayBuffer };
// The worker-resident data. `init` (re)loads it on every worker the client starts.
let rows: Promise<Row[]> = Promise.resolve([]);
function score(label: string, query: string): number {
// Subsequence match: every query character, in order; tighter runs score higher.
let at = -1;
let total = 0;
for (const ch of query.toLowerCase()) {
const next = label.toLowerCase().indexOf(ch, at + 1);
if (next < 0) return 0;
total += next === at + 1 ? 2 : 1;
at = next;
}
return total;
}
async function handle(job: Exclude<Job, { kind: 'init' }>): Promise<[unknown, Transferable[]]> {
switch (job.kind) {
case 'search': {
const hits = (await rows)
.map((row) => ({ ...row, score: score(row.label, job.query) }))
.filter((hit) => hit.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 50);
return [hits, []];
}
case 'csv': {
const lines = new TextDecoder().decode(job.buffer).trim().split('\n');
const values = new Float64Array(lines.length - 1);
for (let i = 1; i < lines.length; i++) {
values[i - 1] = Number(lines[i]!.split(',')[1]);
}
// Hand the column back without copying it.
return [{ header: lines[0], values }, [values.buffer]];
}
}
}
self.onmessage = async (event: MessageEvent<Job>) => {
const job = event.data;
if (job.kind === 'init') {
rows = fetch(job.url).then((r) => r.json() as Promise<Row[]>);
return;
}
try {
const [result, transfer] = await handle(job);
self.postMessage({ id: job.id, result }, { transfer });
} catch (error) {
self.postMessage({ id: job.id, error: String(error) });
}
};

The app wires a resource per job and renders as usual:

import { each, signal } from 'kerfjs';
import { resource } from 'kerfjs/async';
import { disposeScope } from 'kerfjs/scope';
import { debouncedSignal } from 'kerfjs/timing';
import { createCompute } from './compute-client.js';
const spawn = () =>
new Worker(new URL('./compute.worker.ts', import.meta.url), { type: 'module' });
// One client per resource: cancelling terminates the worker, which discards
// every job on it, so a search cancel must not take a CSV import with it.
const search = createCompute(spawn, (post) => post({ kind: 'init', url: '/rows.json' }));
const csv = createCompute(spawn);
type Hit = { id: number; label: string; score: number };
export const query = signal('');
const settledQuery = debouncedSignal(query, 120);
export const hits = resource<Hit[], string>();
const results = document.getElementById('results')!;
const scope = disposeScope(results);
// Re-runs whenever the settled query changes. A newer run aborts this one's
// signal, which terminates the search worker instead of waiting out the job.
scope.effect(() => {
const q = settledQuery.value;
void hits.run(q, (_report, signal) =>
search.call<Hit[]>({ kind: 'search', query: q }, { signal }),
);
});
// The render stays on the main thread and reads plain data from the resource.
scope.mount(results, () => {
const { status, data, error } = hits.value;
if (status === 'failed') return <p role="alert">{String(error)}</p>;
return (
<ul aria-busy={status === 'running' ? 'true' : 'false'}>
{each(data ?? [], (hit) => <li data-key={hit.id}>{hit.label}</li>)}
</ul>
);
});
// Removing the results panel's scope stops both workers and rejects their pending calls.
scope.add(search.dispose);
scope.add(csv.dispose);
type Column = { header: string; values: Float64Array };
export const column = resource<Column, string>();
export async function importCsv(file: File): Promise<void> {
const buffer = await file.arrayBuffer();
// Transferred, not copied: `buffer` is detached (byteLength 0) after this.
await column.run(file.name, (_report, signal) =>
csv.call<Column>({ kind: 'csv', buffer }, { transfer: [buffer], signal }),
);
}

For several jobs at once, put a few clients behind one call(). Each worker runs one job at a time, so a pool sends each call to the least busy one:

// compute-pool.ts — main thread: N workers, each call to the least busy one
import { type Compute, createCompute } from './compute-client.js';
export function createComputePool(
size: number,
...worker: Parameters<typeof createCompute>
): Compute {
const lanes = Array.from({ length: size }, () => ({
compute: createCompute(...worker),
busy: 0,
}));
return {
call<T>(message: Record<string, unknown>, options?: Parameters<Compute['call']>[1]) {
const lane = lanes.reduce((least, next) => (next.busy < least.busy ? next : least));
lane.busy += 1;
return lane.compute.call<T>(message, options).finally(() => {
lane.busy -= 1;
});
},
dispose() {
for (const lane of lanes) lane.compute.dispose();
},
};
}

What each piece does:

  • Superseded results never paint, and superseded work stops. Typing fires a run per settled query. When a newer run starts, the resource aborts the older run’s AbortSignal and its stale-response guard drops the older result, so the list only shows the latest query’s hits. A worker runs one message at a time and never sees a newer message or an abort until its current job returns, so call() stops a running job by terminating the worker. The next call starts a fresh worker, and init loads the worker-resident data into it again. The debounce keeps cancellations rare. The abort rejection belongs to the superseded run, so the guard drops it and the resource never moves to failed because of it.
  • Give each job its own client. Terminating a worker discards every job on it, so a cancelled search must not share a worker with a CSV import. In a pool, cancelling a call terminates that call’s worker, along with any other call queued on it.
  • Errors land in value.error. A throw inside the worker becomes an error reply: that call() rejects, the resource moves to failed without an unhandled rejection, and the worker keeps serving. A worker that fails to load (a missing file, a syntax error) or crashes fires error instead. That rejects every pending call and every later one, so no resource() stays running forever.
  • Dispose with the view. dispose() terminates the worker and rejects pending and later calls. Register it on the view’s scope (kerfjs/scope), so removing the view stops its workers along with its mounts and effects.
  • Transfer large binary data; let small results copy. Passing an ArrayBuffer (or a typed array’s .buffer) in transfer moves it between threads without a copy, and the sender’s copy becomes detached. Plain objects and strings are copied by structured clone, which is fast at these sizes; a megabyte-scale string costs well under a millisecond on a desktop. Transferring pays off for large typed arrays and file contents.
  • Keep the worker’s results as data. Send rows, numbers, and strings back, not HTML: the render function, each(), and the morph stay on the main thread, where they can read signals and preserve focus and selection.

Keyed reactive list — kerfjs/list subpath

Section titled “Keyed reactive list — kerfjs/list subpath”

Optional subpath (import { bindList } from 'kerfjs/list') providing bindList — a keyed list with a live per-row mount and optional viewport virtualization. It is a deliberate second list API, distinct from each(): reach for it when you need surgical per-row updates or windowing; each() stays the default for item-owned-state lists rendered to HTML strings. See docs/4-render.md for the tradeoff.

bindList<T>(parent, source, options): BindListHandle

Section titled “bindList<T>(parent, source, options): BindListHandle”
const dispose = bindList(listEl, itemsSignal, {
key: (row) => row.id,
render: (row) => <span class={selected} data-id={row.id}>{row.label}</span>,
tag: 'li', // row element tag (default 'div')
virtualize: { rowHeight: 32 }, // optional windowing
});

Binds a keyed list to parent (whose children bindList owns — by default it appends/moves rows to the very end; pass before to keep the rows as a block ending before a fixed trailing sibling, so a list can share its parent with an “add” button or indicator), driven by source — anything with a tracking .value array read, so a signal<readonly T[]> or an arraySignal<T> both work. Every snapshot is preflighted for unique keys before any DOM mutation; a duplicate throws an error naming the key and both indices. If an arraySignal transition is rejected, its unusable patches are drained and the next valid state takes a full snapshot path before granular reconciliation resumes. A granular row-render exception is reported to the caller, then the next source update also takes the snapshot path; this repairs any earlier patches from the same partially applied batch before granular updates resume. When the source is an arraySignal and the list is not virtualized, bindList applies valid insert/remove/move/update patches granularly (O(patches)) instead of diffing the snapshot; a plain signal<T[]>, a virtualized list, a replace(), or an arraySignal shared with another consumer fall back to the keyed diff (all correct — the granular path is a transparent optimization). Returns a disposer that tears down every row mount, the scroll listener, and the source subscription.

  • Per-row reactivity (content mode). Return a MountResult (JSX / SafeHtml) and each row is individually mount()ed, so a signal the row’s render reads updates just that row (a fine-grained binding or a one-row morph) — its siblings don’t re-render. Read signals in render for reactivity (external state like a selectedId, or signals the item carries); keep item objects stable and drive structure (add/remove/move) through the source. A row whose item object identity changes is rebuilt (same rule as each()’s memo).

  • Own the row element (element mode). Return an HTMLElement (or { el, update?, dispose? }) and that element is the row — so the app owns its tag, class, data-*, and listeners, exactly how apps already build keyed rows (<div class="ticket-row" data-id=…>, a <tr> in a <tbody>). kerf keys / moves / reuses it: the SAME element survives an append, a remove elsewhere, a reorder, or a fresh item object at the same key (so focus / scroll / selection / imperative listeners are preserved). Your dispose runs only when the row is genuinely removed (or the list disposes) — not for surviving rows. Refresh a reused element’s content by reading signals inside it, or by returning an update(item) that kerf calls on the existing element whenever the item changes at that key. A list may mix the two modes per row. (Content-mode render runs once extra at row creation for the mode probe, so keep render a pure projection — which bindList already requires.)

  • Same-reference updates. arraySignal.update() can mutate and return the same item object. Its version stamp refreshes every bindList consumer: content mode remounts inside the existing row element, while element mode calls its update(item) hook. A plain signal<T[]> does not stamp in-place mutations; write a new item object or use signals in the row for that source.

  • Virtualization. With virtualize: { rowHeight, overscan? } only the rows in the scroll viewport are rendered. rowHeight (a RowHeight<T>) is the height model:

    • number — a single fixed pixel height for every row; O(1) windowing.
    • (item, index) => number — app-declared variable heights, derived purely from the item and its index (a known line count, an image’s intrinsic aspect ratio, a server-provided height). kerf builds a prefix sum of the heights — rebuilt when the source array changes, not per scroll frame — and binary-searches it for the visible window, so a scroll pays only O(log n). Return a non-negative number of pixels.
    • { estimate } — measured heights, for rows whose height is only known after layout. kerf sizes an unmeasured row by estimate (a number or an (item, index) => number); the app reports each row’s real height with the handle’s setHeight(key, px) (keyed by the list key, so a report survives reorders), or drives it automatically with observeRowHeights. When a remeasured row sits above the viewport, kerf adjusts scrollTop by the height delta so on-screen content doesn’t jump. The correction is settled when the next animation frame runs, not when each report arrives: only rows still in the source and still entirely above scrollTop at that moment count, several reports for one key within a frame net out to one change, and dispose() before the frame writes nothing.

    bindList renders the rows into an inner sizer element it creates inside parent and sets that sizer’s padding-top/padding-bottom so parent’s scrollHeight stays honest — the padding lives on the sizer, not on parent, because padding counts toward clientHeight and would otherwise break the window math. It sizes each visible row to its rowHeight for you. overscan (default 3) renders extra rows above/below the viewport. Give parent a fixed height + overflow: auto in your CSS (the rows are parent > sizer > row).

    More virtualize options:

    • mode ('window' | 'content-visibility', default 'window') — the virtualization strategy. 'window' is the JS windowing described above (bounded DOM nodes; off-window rows removed). 'content-visibility' instead keeps every row in the DOM and sets content-visibility: auto + contain-intrinsic-size: 0 <rowHeight>px on each, so a supporting engine (Chromium, Safari 18) skips the layout/paint of off-screen rows while all rows stay findable (find-in-page, the a11y tree, and anchor links all work). In this mode rowHeight is only the contain-intrinsic-size placeholder (no windowing math), setHeight / observeRowHeights are no-ops (the browser owns measurement), minRows is ignored (all rows already render), and no scroll listener / ResizeObserver is installed; handle.container / containerClass / containerId still work. On an engine without content-visibility the CSS is inert — all rows still render (correct, still findable), just without the skip optimization. The mode choice is the app’s, about list size: 'content-visibility' for medium lists where findability beats the node ceiling; 'window' for very large (100k-row) lists.
    • minRows — render every row (no windowing, zero padding) while the list is shorter than minRows, and window only at or above it. The DOM structure (the inner sizer) is identical either way, so the call site never branches on list length. A fully-rendered short list is friendlier to find-in-page (Cmd+F), screen readers, and DOM-count assertions, which only see rows actually in the DOM. Crossing the threshold in either direction switches automatically. (No effect under mode: 'content-visibility', which renders all rows regardless.)
    • containerClass / containerId — set on the inner sizer kerf creates, so it’s reachable from CSS and test selectors without guessing at parent.lastElementChild.
    • Resizing. kerf re-windows on parent’s scroll and, where ResizeObserver exists, when parent itself resizes. So a list mounted before layout (a hidden tab, clientHeight 0) fills in once it’s sized, and a container resized while open re-windows — neither needs a synthetic scroll. (Absent ResizeObserver, it’s scroll-only, so ensure parent is laid out at mount.)
    • Accepted ranges. Every height — a fixed rowHeight, an estimate, each value a height callback returns, and each setHeight(key, px) report — must be a finite, non-negative number of pixels; zero-height rows are allowed. A fixed rowHeight must be greater than 0 in 'window' mode (it divides the scroll offset). overscan and minRows must be non-negative integers. Invalid configuration throws a bindList:-prefixed RangeError before any DOM is touched; an invalid callback return throws a RangeError from that render, and an invalid setHeight report throws one without changing the height model. (The only TypeError bindList throws is for a malformed source: an object carrying the arraySignal brand but no patch queue.)
    • Findability & a11y tradeoff. Off-window rows are removed from the DOM (not just hidden), so a virtualized list is only partially reachable: find-in-page (Cmd/Ctrl+F), screen readers / the accessibility tree, and anchor links / scrollIntoView all match only the visible window — a hit, an announced row, or a linked element that’s been windowed out isn’t in the DOM to find. Convey the true total through ARIA (aria-rowcount / aria-setsize) if it matters, and to deep-link a specific off-window row, scroll the window to its offset first. When full findability matters more than the DOM node ceiling, don’t virtualize — render a plain bindList, or set minRows above the list’s length (same DOM shape, no windowing) so every row stays live.

bindList returns a BindListHandle — the disposer you call to tear the list down, with a setHeight(key, px) method for the measured mode (a no-op otherwise) and a container property (the inner sizer for a virtualized list, undefined otherwise). If the first render throws (a row render, a height callback, a duplicate key), bindList releases every row that pass already created — content-row mounts, element-mode dispose callbacks, and their DOM; a virtualized sizer is never attached — and rethrows the original error, so the same parent can be bound again.

Options (BindListOptions<T>): key (stable, unique per-row key — a ListKey, i.e. string | number; duplicates are rejected before mutation), render (returns the row — a MountResult for content mode, or a RowElement<T> (HTMLElement / { el, update?, dispose? }) for element mode), tag (content mode only), before (a Node or () => Node | null — keep the rows as a contiguous block ending just before it, for a parent shared with trailing controls; the node must be a child of parent; ignored when virtualized), virtualize.

The batteries-included measurement path for a { estimate } virtualized list: installs one ResizeObserver over the current visible rows and forwards each row’s offsetHeight to handle.setHeight, re-observing as the window shifts. Returns a disposer. It is deliberately separate from bindList (which never observes rows — its only ResizeObserver use is the optional parent-resize observer described above) — measure however you like and call handle.setHeight yourself instead. A no-op for a non-virtualized handle or where ResizeObserver is unavailable (SSR).

const list = bindList(scrollEl, source, {
key,
render,
virtualize: { rowHeight: { estimate: 64 } },
});
const stopMeasuring = observeRowHeights(list);

ListKey, ListSource<T>, RowElement<T>, RowHeight<T>, BindListHandle, and BindListOptions<T> are exported from kerfjs/list. RowHeight<T> is number | ((item, index) => number) | { estimate: number | ((item, index) => number) }; BindListHandle is (() => void) & { setHeight(key: ListKey, height: number): void; container?: HTMLElement }.

Timing primitives — kerfjs/timing subpath

Section titled “Timing primitives — kerfjs/timing subpath”

Optional subpath (import { debounce, throttle, debouncedSignal } from 'kerfjs/timing') for the let timer; clearTimeout(timer); timer = setTimeout(…) pattern every app hand-rolls, with disposer-shaped ergonomics. debounce/throttle are dependency-free; only debouncedSignal pulls in signals (no render core), so the subpath is tiny.

const save = debounce(() => persist(state), 300);
input.addEventListener("input", save);
// …on teardown: save.cancel();

Trailing-edge debounce: fn runs ms after calls stop, with the most recent arguments; each call within the quiet window resets the timer. Returns a Debounced<A> — callable like fn, plus cancel() (drop a pending call) and flush() (run it immediately and clear the timer). The argument tuple A is inferred from fn.

const onScroll = throttle(() => measure(), 100);
window.addEventListener("scroll", onScroll);

Leading-plus-trailing throttle: fn runs immediately on the first call, then at most once per ms; calls during a cooldown collapse to a single trailing call (with the latest arguments) at the window’s end. Returns a Throttled<A> with cancel() (drop a pending trailing call and reset the window) and flush(). Unlike debounce’s, throttle’s flush() runs a pending trailing call now but leaves the current cooldown running, so a call made right after a flush is still collapsed into the window’s trailing call. The cooldown is active while fn runs, including for the trailing invocation, so fn may call cancel() itself to reset the window immediately; a reentrant call stays throttled unless the callback first cancels.

debouncedSignal<T>(source, ms): DebouncedSignal<T>

Section titled “debouncedSignal<T>(source, ms): DebouncedSignal<T>”
const query = signal("");
const debouncedQuery = debouncedSignal(query, 250); // trails query by 250ms
// render / computed / effect off debouncedQuery.value — repaints only after typing settles

A read-only signal that trails source by ms (trailing-edge): writes to source reschedule, and the derived value settles once writes go quiet, so it composes inside the reactive graph (computed/effect/mount) instead of beside it. It holds a live subscription to source until you call its dispose(), which unsubscribes from source and drops any pending trailing write (the signal keeps its last value; idempotent). An app-lifetime, module-scope signal never needs to dispose; a per-row or per-component one should — register d.dispose on the owner’s scope (kerfjs/scope) so it goes away with the view.

Debounced<A> and Throttled<A> are exported from kerfjs/timing for annotating the returned callables, and DebouncedSignal<T> (a ReadonlySignal<T> plus dispose()) for debouncedSignal’s result.

Keyed subtree replacement — kerfjs/remount subpath

Section titled “Keyed subtree replacement — kerfjs/remount subpath”

Optional subpath (import { remountOn } from 'kerfjs/remount') for the opposite of kerf’s morph-by-default: replace a subtree wholesale when a key changes instead of morphing it in place. The folk pattern is a monotonic counter spent as data-key={gen-${n}} on a data-morph-skip div; remountOn names it. Reach for it when a library-owned subtree (a highlighted diff, a chart, an editor) must be torn down and rebuilt on fresh DOM so the library re-initializes rather than the morph patching stale internals underneath it. See docs/4-render.md.

remountOn<K>(parent, key, render, options?): () => void

Section titled “remountOn<K>(parent, key, render, options?): () => void”
// Replace the diff pane whenever the file (or diff mode) changes:
const stop = remountOn(paneEl, () => fileId.value, () => <DiffView id={fileId.value} />);
// same key → the subtree is left entirely alone (no morph, no rebuild)
// key change → the old subtree + its mounts are disposed, a fresh one is mounted

remountOn owns parent’s children (like mount() / bindList). It watches key — a ReadonlySignal<K> or a thunk () => K that reads signals — and, whenever the key changes (by Object.is), disposes the current subtree (and its nested mounts) and renders a fresh one via mount(parent, render). An unchanged key (including a thunk whose inputs moved but whose value stayed equal) leaves the subtree untouched, so per-row reactivity inside render still updates in place. Returns an idempotent disposer: its first call tears down the current subtree and stops watching, then relinquishes ownership so repeated calls leave later external content in parent untouched.

Options (RemountOptions): onMount(root) runs after each (re)mount with the live subtree (parent) — the blessed place to bind an imperative widget, since render returns a string with no live node yet. It may return a cleanup that runs before the next remount and on dispose. Returning attach’s disposer here makes teardown synchronous (before the DOM is cleared) rather than relying on its MutationObserver:

import { attach } from 'kerfjs/attach';
remountOn(paneEl, () => fileId.value, () => <div class="pane" data-morph-skip />, {
onMount: (root) => attach(root.querySelector('.pane')!, (el) => {
const chart = Chart.mount(el);
return () => chart.destroy(); // runs on the next key change and on dispose
}),
});

RemountKey<K> (ReadonlySignal<K> | (() => K)) and RemountOptions ({ onMount?: (root: HTMLElement) => (() => void) | void }) are exported from kerfjs/remount.

Node-lifecycle adapter — kerfjs/attach subpath

Section titled “Node-lifecycle adapter — kerfjs/attach subpath”

Optional subpath (import { attach } from 'kerfjs/attach') that binds a non-kerf widget’s lifecycle to a single existing DOM node: run a setup against the node and auto-run its teardown when that node leaves the document. data-morph-skip lets a library own a subtree so kerf won’t touch it, but nothing tears that widget down when the node is replaced/removed; attach closes that seam. DOM only (a MutationObserver, plus a temporary animation-frame connection watch when the node starts disconnected) — no signals, no render core, so it’s the smallest subpath.

This is not React’s useEffect: there’s no dependency array, no re-run, and no render-phase or hook-order scoping — setup runs once, right now, whether or not the node is connected yet. It’s closer to a Web Component’s connectedCallback/disconnectedCallback pair, Svelte’s onMount(() => () => cleanup), or Solid’s onCleanup. (Related: observeRemovals in kerfjs/scope also auto-disposes on removal via a MutationObserver, but scoped to a subtree’s collected disposers rather than one node’s setup/teardown pair — reach for that when collecting many disposers under an element, and for attach when binding one widget to one node.)

attach(canvasEl, (el) => {
const chart = D3.mount(el);
return () => chart.destroy(); // runs when el leaves the DOM (or on dispose)
});

setup(node) runs immediately and may return a teardown function. If node starts disconnected, that state is preparation rather than removal: kerf waits for its first connection, then tears down if the node subsequently leaves the document. Connection through an inserted ancestor or direct insertion into an already-connected shadow root is supported, as are direct removal, ancestor removal, light-DOM insertion and removal in one observer batch, and removal of a containing shadow host. While the node remains disconnected, a temporary animation-frame check covers shadow-root insertion that a document observer cannot see; explicitly dispose an abandoned node to cancel that watch. A node that never connects is torn down only by the returned disposer. The teardown runs once — whichever comes first — on that post-connection removal (so a morph swap or remountOn replacement triggers it) or when the returned disposer is called. The disposer is idempotent, so a mount() / Scope can drive teardown explicitly without double-firing. A keyed reorder (an each() or bindList row moved with moveBefore() / insertBefore()) is not a removal: the node is connected again by the time the observer runs, so the widget survives. A removal is final, though: re-inserting the same node in a later task does not run setup again (and a later removal does not run teardown again) — call attach() again to revive it. Re-creation is not handled here — a fresh node is a fresh attach() call; pair it with kerfjs/remount, which replaces the node and re-runs your bind on the new one. Setup type: AttachSetup.

AttachSetup ((node: Element) => (() => void) | void) is exported from kerfjs/attach.

Optional subpath (import { createRouter } from 'kerfjs/router') providing a client-side router — the “postcard router”: route matching + navigate + delegate()-based link interception + a keyed outlet, and deliberately nothing more. The kerf core stays router-free (the overview doc’s “Not a router” is about the runtime); this adds nothing to the main barrel until imported, on the same footing as kerfjs/list / kerfjs/overlay. The scope boundary is deliberate — no nested layouts / loaders / lazy routes / guards / SSR; compose those with kerf primitives.

const router = createRouter({
routes: [
{ path: '/', component: () => <Home /> },
{ path: '/users/:id', component: ({ id }) => <User id={id} /> },
{ path: '*', component: () => <NotFound /> }, // catch-all — last
],
mode: 'history', // 'history' (default) | 'hash'
base: '/app', // optional (history mode)
interceptLinks: true, // default; false disables the delegated <a> interceptor
});
mount(app, () => (
<div>
<nav><a href="/users/1" class={router.activeClass('/users', 'active')}>Users</a></nav>
{router.outlet()}
</div>
));

Creates a router bound to the browser history (reads the current location immediately, installs a popstate listener — plus hashchange in hash mode — and, unless interceptLinks: false, one delegated link interceptor). Returns a RouterHandle (a closure — no module-global state, like defineStore):

In history mode, base is stripped only on an exact match or at a following / segment boundary; a base of /app therefore owns /app and /app/users, but not /apple.

  • route — ReadonlySignal<RouteState> = { path, params, query, hash }. params is a Record<string, string> from the matched pattern (URL-decoded), or {} when no route matches; query is a URLSearchParams. A tracked read. In hash mode the route lives after # (#/users/7?tab=1 → path /users/7, query tab=1) and route.hash is always ''.
  • navigate(path, { replace?, state? }) — push (or replace) a history entry and update route; path may include ?query / #hash. Navigating to the URL that is already current replaces the entry instead of pushing a duplicate (as a browser does for a link to the current page) and republishes route only if it changed. After dispose(), navigate() is a no-op.
  • back() / forward() — history.back() / history.forward().
  • match(pattern) → ReadonlySignal<boolean> — reactive active-check: a literal path-prefix comparison, true when route.path equals pattern or continues it at a / boundary (match('/users') is true on /users/7, not on /users-admin); match('/') is exact. The pattern never goes through the route matcher, so :param / * segments are compared as literal text — match('/users/:id') is never true; pass the static prefix ('/users'). A navigation notifies only the links whose answer changed, however many links there are.
  • activeClass(pattern, className) → ReadonlySignal<string> — className while match(pattern) is active, else ''; spread into a class hole.
  • outlet() → the routed view — call it inside a mount() render. It renders the matched component in a keyed wrapper (data-key = the matched route’s pattern), so kerf’s keyed morph replaces the page wholesale on a route change (fresh DOM) and reconciles in place on a same-route param change (preserving scroll / focus). No new machinery. The matched component is called as component(params, route); when no route matches (no * fallback), outlet() returns null and renders nothing.
  • dispose() — remove the popstate (plus hash-mode hashchange) and link listeners, and turn later navigate() calls into no-ops (idempotent).

Route patterns (tried in order, first match wins): static (/about), :param (/users/:id → params.id), a trailing *rest wildcard (/files/*rest → params.rest, the remaining segments joined by /; a bare * segment matches without capturing), and * as the catch-all fallback (list it last).

Link interception intercepts only plain in-app navigations — left-click, no modifier keys, not already defaultPrevented (an already-handled click only re-syncs the route from the URL, so a second router on the page follows the first), no download, no target other than _self (a target="_self" link is still intercepted), not rel="external" / data-router-ignore, same-origin (under base in history mode; an in-app #/… link in hash mode). Everything else falls through to the browser.

Handler order matters. The interceptor is a bubbling click listener on document.body and navigates the moment it sees an eligible click, so an app handler that runs after it — registered on body after createRouter, or on document / window — can call preventDefault() only after the router has already navigated. To keep the router off a link, mark it data-router-ignore or rel="external", or call preventDefault() in a handler that runs first (on the link or an element inside it, an ancestor below body, or body before createRouter) — the interceptor skips a click that is already defaultPrevented.

One router per document is the supported shape. A second router follows link clicks and history traversal (popstate), but not another router’s programmatic navigate() — pushState / replaceState fire no popstate, and routers do no cross-router broadcasting — so it stays on the old route until the next popstate or link click. To route two views, share one createRouter handle.

Named and wildcard parameters fail closed to no-match when URL decoding encounters a malformed percent escape; router creation and navigation do not throw URIError, and matching continues to the next route.

RouteState, RouteComponent, RouteDef, NavigateOptions, RouterOptions, and RouterHandle are exported from kerfjs/router. RouteComponent is (params: Record<string, string>, route: RouteState) => MountResult; a RouteDef is { path: string; component: RouteComponent }.