API reference
Everything kerf exports, organized by module. Imported via import { … } from 'kerfjs' unless noted.
Reactivity
Section titled “Reactivity”signal<T>(value?: T): Signal<T>
Section titled “signal<T>(value?: T): Signal<T>”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).
batch(fn: () => void): void
Section titled “batch(fn: () => void): void”Run fn, deferring effect re-runs until fn returns. Multiple writes inside fn produce a single re-run.
Signal<T> (type)
Section titled “Signal<T> (type)”interface Signal<T> { value: T;}ReadonlySignal<T> (type)
Section titled “ReadonlySignal<T> (type)”interface ReadonlySignal<T> { readonly value: T;}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).
Stores
Section titled “Stores”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. 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 KERF_DEV_WARN_NARROW_SET=1 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).
resetAllStores(): void
Section titled “resetAllStores(): void”Calls reset() on every store registered via defineStore().
Store<TState, TActions> (type)
Section titled “Store<TState, TActions> (type)”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"); // Viteif (process.env.NODE_ENV !== "production") await import("kerfjs/dev"); // webpack / NodeThat 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, 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: every diagnostic reads its switch at call time
except untrackedSignals, which is chosen when a signal is created — enabling
that one prints its coverage boundary once.
The subpath also re-exports clearDevHooks(), installDevHooks() and
devHooks so a consumer’s own test suite can assert production-shaped
behavior without reloading modules.
Order matters in one place: signal() chooses its constructor at creation
time, so signals created before the dev entry runs are invisible to
KERF_DEV_WARN_UNTRACKED_SIGNALS — and since static imports hoist above a
top-level await import(), that is the common case. To cover module-scope
signals, make import 'kerfjs/dev' the first static import of a dev-only entry
file. Opting into that warning prints the boundary once so the 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.
Render
Section titled “Render”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;rootElitself is preserved. - Matches elements by
id, thendata-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). - It’s a list parent owned by
each(...)(children-only short-circuit;each’s reconciler owns those rows). Attribute morphing on the parent itself still happens. fromEl.isEqualNode(toEl)(no work needed).- It’s the focused
[contenteditable](entire subtree preserved on this morph; see the Conventions used bymountsection below anddocs/4-render.md).
- It has
- 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.
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); attributes the template never mentions are left alone.
import { morph, raw } from "kerfjs";
morph(liveCard, freshlyBuiltCardEl); // Element templatemorph(liveCard, '<article class="card">…</article>'); // raw HTML stringmorph(liveCard, raw(htmlFromServer)); // SafeHtmlWhen 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 <details> / <dialog>’s user-agent-owned open attribute. 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/SafeHtmltemplate is parsed as HTML with no escaping and no URL screening — the same trust model asinnerHTML/raw(). AnElementtemplate’s attributes are copied to the live tree verbatim, includingon*inline handlers andjavascript:URLs. So amorph()template must be markup you trust: built via JSX, or sanitized upstream (DOMPurify). Never pass unsanitized user input as amorph()template. (This is distinct from themount()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 });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 KERF_DEV_WARN_STALE_INDEX=1 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.
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.
Event delegation
Section titled “Event delegation”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.
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).
DelegateOptions (type)
Section titled “DelegateOptions (type)”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(name, value) — static form
Section titled “attr(name, value) — static form”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(name) — dynamic form
Section titled “attr(name) — dynamic form”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,);AttrSpec<N, V> (type)
Section titled “AttrSpec<N, V> (type)”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. 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.
JSX runtime
Section titled “JSX runtime”import 'kerfjs/jsx-runtime' — TypeScript / esbuild config
Section titled “import 'kerfjs/jsx-runtime' — TypeScript / esbuild config”{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "kerfjs", },}SafeHtml (class)
Section titled “SafeHtml (class)”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.
JSXChildren (type)
Section titled “JSXChildren (type)”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.
raw(html: string): SafeHtml
Section titled “raw(html: string): SafeHtml”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 needraw(). - Composing markup? Build a
SafeHtmlthe normal way — a JSX expression, thehtmltagged template (kerfjs/html),each(), or a component function returning JSX. All produce trustedSafeHtmlwithout 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-arglint rule flags araw()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 atwarninconfigs.recommended, so an un-acknowledged dynamicraw()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.
Fragment (component)
Section titled “Fragment (component)”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 })).
html`…` — kerfjs/html subpath
Section titled “html`…` — kerfjs/html subpath”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.
Fine-grained signal bindings
Section titled “Fine-grained signal bindings”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.
Dangerous URL filter
Section titled “Dangerous URL filter”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.
Direct JSX → DOM
Section titled “Direct JSX → DOM”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 throughDOMParser('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 aDocumentFragmentcontaining every top-level node, including text nodes. Pass the result straight toparent.appendChild(...)/parent.replaceChildren(...)/parent.append(...)— the DOM insertion APIs inline aDocumentFragment’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 asinnerHTML/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.
Conventions used by mount
Section titled “Conventions used by mount”| 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. |
Overlays — kerfjs/overlay subpath
Section titled “Overlays — kerfjs/overlay subpath”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.
overlay(content, options?): OverlayHandle
Section titled “overlay(content, options?): OverlayHandle”const dialog = overlay(<Settings />, { dismiss: ['escape', 'backdrop'], initialFocus: 'input' });await dialog.result; // resolves when closeddialog.close(value); // or close it yourselfAppends 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?), 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, restores focus to the previously-focused element, and resolves result. Concurrent plain-<div> fallback overlays arbitrate dismissal from the top down: one Escape, backdrop, or outside click closes only the active modal or non-modal surface.
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); 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); and native (see 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 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.
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.
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 (return a non-empty error string to block OK — it shows inline), plus container / className.
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. Options (FormOptions): title, okText / cancelText, container / className.
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. 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 bagsawait 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 clickAn 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 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. 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), dismiss, initialFocus (default false), outsideIgnore (merged with the anchor), onDismiss, container / className, 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.
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-shotconst stop = autoReposition(hintEl, badgeEl, { gap: 6 }); // stays glued; stop() to unbindpopover()’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. autoReposition positions once, then re-runs on scroll (capture — catches inner scroll containers) and resize, returning a disposer that removes the listeners. Both take AnchorPositionOptions (placement, align, gap).
tooltip(anchor, content, options?): () => void
Section titled “tooltip(anchor, content, options?): () => void”const stop = tooltip(buttonEl, "Delete this item"); // hover/focus tooltip, above by defaultA 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. 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 / className (default 'kerf-tooltip').
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(content, options?): ToastHandle
Section titled “toast(content, options?): ToastHandle”toast("Saved", { variant: "success" });const { el, dismiss } = toast("Uploading…", { duration: 0 }); // sticky; dismiss() when donetoast("Only the latest shows", { mode: "replace" }); // collapse-to-latestShows a non-modal, auto-dismissing notification, stacked in a shared body-level region (lazily created, or 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.
Overlay types
Section titled “Overlay types”OverlayHandle, OverlayContent, OverlayOptions, 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.
Dispose scopes — kerfjs/scope subpath
Section titled “Dispose scopes — kerfjs/scope subpath”Optional subpath (import { disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope') that ties a set of disposers 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. No module-level mutable state — scopes live in a WeakMap keyed by element.
disposeScope(el): Scope
Section titled “disposeScope(el): Scope”const s = disposeScope(card);s.mount(card, renderCard); // mounts AND registers the disposers.effect(() => sync(card));s.delegate(card, "click", ".del", del);s.add(() => observer.disconnect()); // any () => void disposer// …later:s.dispose(); // runs them all, best-effort, idempotentReturns 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 — then resets; idempotent).
disposeSubtree(root): void
Section titled “disposeSubtree(root): void”disposeSubtree(feed); // dispose every scope in feed, root includedfeed.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.
observeRemovals(root): () => void
Section titled “observeRemovals(root): () => void”const stop = observeRemovals(document.body); // auto-dispose on removal, app-wideInstalls 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.
Scope (type)
Section titled “Scope (type)”The handle returned by disposeScope, exported for annotation: { add, mount, effect, delegate, dispose } (see disposeScope above).
Async state — kerfjs/async subpath
Section titled “Async state — kerfjs/async subpath”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.statususers.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(). Methods:
run(fetcher): Promise<T | undefined>— setsrunning, thencompleted(withdata) orfailed(witherror), guarding against stale responses: only the latestrunmay 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 invalue.error; the returned promise resolves the data (orundefinedon failure) for callers who want to await. Previousdatais preserved across a re-run and on failure (stale-while-revalidate).run(input, fetcher): Promise<T | undefined>— same as above, plus recordsinputasvalue.inputfor therunning/completed/failedstates 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 byvalue.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 toidle, 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 thecacheKeycache, 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 (.lengthis the size);clearCache(key)evicts one key (or the whole cache with no argument) without touchingvalue.
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.
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 revalidateseffect(() => { 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.
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?.fileIdResource types
Section titled “Resource types”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).
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 individuallymount()ed, so a signal the row’srenderreads updates just that row (a fine-grained binding or a one-row morph) — its siblings don’t re-render. Read signals inrenderfor reactivity (external state like aselectedId, 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 aseach()’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). Yourdisposeruns 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 anupdate(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-moderenderruns once extra at row creation for the mode probe, so keeprendera pure projection — which bindList already requires.) -
Virtualization. With
virtualize: { rowHeight, overscan? }only the rows in the scroll viewport are rendered.rowHeight(aRowHeight<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 byestimate(anumberor an(item, index) => number); the app reports each row’s real height with the handle’ssetHeight(key, px)(keyed by the listkey, so a report survives reorders), or drives it automatically withobserveRowHeights. When a remeasured row sits above the viewport, kerf adjustsscrollTopby the height delta so on-screen content doesn’t jump.
bindListrenders the rows into an inner sizer element it creates insideparentand sets that sizer’spadding-top/padding-bottomsoparent’sscrollHeightstays honest — the padding lives on the sizer, not onparent, because padding counts towardclientHeightand would otherwise break the window math. It sizes each visible row to itsrowHeightfor you.overscan(default 3) renders extra rows above/below the viewport. Giveparenta fixed height +overflow: autoin your CSS (the rows areparent > sizer > row).More
virtualizeoptions: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 setscontent-visibility: auto+contain-intrinsic-size: 0 <rowHeight>pxon 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 moderowHeightis only thecontain-intrinsic-sizeplaceholder (no windowing math),setHeight/observeRowHeightsare no-ops (the browser owns measurement),minRowsis ignored (all rows already render), and no scroll listener /ResizeObserveris installed;handle.container/containerClass/containerIdstill work. On an engine withoutcontent-visibilitythe CSS is inert — all rows still render (correct, still findable), just without the skip optimization. Themodechoice 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 thanminRows, 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 undermode: '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 atparent.lastElementChild.- Resizing. kerf re-windows on
parent’sscrolland, whereResizeObserverexists, whenparentitself resizes. So a list mounted before layout (a hidden tab,clientHeight0) fills in once it’s sized, and a container resized while open re-windows — neither needs a synthetic scroll. (AbsentResizeObserver, it’s scroll-only, so ensureparentis laid out at mount.) - Accepted ranges. Every height — a fixed
rowHeight, anestimate, each value a height callback returns, and eachsetHeight(key, px)report — must be a finite, non-negative number of pixels; zero-height rows are allowed. A fixedrowHeightmust be greater than 0 in'window'mode (it divides the scroll offset).overscanandminRowsmust be non-negative integers. Invalid configuration throws abindList:-prefixedRangeError/TypeErrorbefore any DOM is touched; an invalid callback return throws from that render, and an invalidsetHeightreport throws without changing the height model. - 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 /
scrollIntoViewall 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 plainbindList, or setminRowsabove 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.
observeRowHeights(handle): () => void
Section titled “observeRowHeights(handle): () => void”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 depends on ResizeObserver) — 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);List types
Section titled “List types”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.
debounce<A>(fn, ms): Debounced<A>
Section titled “debounce<A>(fn, ms): Debounced<A>”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.
throttle<A>(fn, ms): Throttled<A>
Section titled “throttle<A>(fn, ms): Throttled<A>”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 the same cancel() / flush() shape. 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): ReadonlySignal<T>
Section titled “debouncedSignal<T>(source, ms): ReadonlySignal<T>”const query = signal("");const debouncedQuery = debouncedSignal(query, 250); // trails query by 250ms// render / computed / effect off debouncedQuery.value — repaints only after typing settlesA 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 for its lifetime (like a module-scope effect) — intended for app-lifetime signals; for a disposable variant, drive your own effect with debounce.
Timing types
Section titled “Timing types”Debounced<A> and Throttled<A> are exported from kerfjs/timing for annotating the returned callables.
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 mountedremountOn 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 }),});Remount types
Section titled “Remount types”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(node, setup): () => void
Section titled “attach(node, setup): () => void”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. 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.
Attach types
Section titled “Attach types”AttachSetup ((node: Element) => (() => void) | void) is exported from kerfjs/attach.
Router — kerfjs/router subpath
Section titled “Router — kerfjs/router subpath”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.
createRouter<>(options): RouterHandle
Section titled “createRouter<>(options): RouterHandle”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 }.paramsis aRecord<string, string>from the matched pattern (URL-decoded);queryis aURLSearchParams. A tracked read.navigate(path, { replace?, state? })— push (or replace) a history entry and updateroute;pathmay include?query/#hash.back()/forward()—history.back()/history.forward().match(pattern)→ReadonlySignal<boolean>— reactive active-check: true whenroute.pathequalspatternor is nested under it (match('/users')is true on/users/7);match('/')is exact.activeClass(pattern, className)→ReadonlySignal<string>—classNamewhilematch(pattern)is active, else''; spread into aclasshole.outlet()→ the routed view — call it inside amount()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.dispose()— remove the popstate / link listeners (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, no target/download, 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.
Router types
Section titled “Router types”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 }.