A tiny reactive UI framework. The smallest cut.
brianwestphal.github.io/kerf — docs · examples · live demo
~13 KB. No virtual DOM. No compiler. No magic. Reactive UI that touches only the bytes that changed.
import { signal, mount, delegate } from 'kerfjs';
const count = signal(0);
const app = document.getElementById('app')!;
mount(app, () => (
<div>
<button data-action="inc">+</button>
<span>{count}</span>
</div>
));
delegate(app, 'click', '[data-action="inc"]', () => count.value++);That's it. Your JSX renders to HTML strings, and handing the signal itself to the text hole binds that one node directly. Structural reads still re-run the render, where kerf's native diff applies the minimum DOM mutations needed to make the live tree match.
Here's the whole development loop — write a component, run the dev server, click around, edit, watch the browser pick it up:
Quick start · Why kerf · Quick tour · Docs & examples
npm install kerfjsFor an existing project, install the AI guidance, strict TypeScript/ESLint feedback, and package scripts with a reviewed dry run:
npx kerfjs setup # inspect a value-level plan
npx kerfjs setup --write --yes # apply itThe command detects core versus @kerfjs/ui, supports deterministic npm,
pnpm, and Yarn workspace selection plus manager-correct offline installs, and
never adds UI to a core-only project. See
docs/24-ai-first-setup.md.
Write plain .tsx and build with your existing esbuild / Vite / tsup — no extra plugin. New here? Read the 5-minute orientation, or open a complete example.
-
~13 KB in the browser. ~13 KB minified + gzipped including
@preact/signals-core(~13 KB witharraySignal). The optional setup CLI has its own npm dependencies; they stay out of the browser bundle. No virtual DOM, no scheduler, no concurrent-mode machinery. On the official krausest benchmark kerf sits in the same cluster as Vue, Lit, and vanjs; Solid's compiler leads the update-path benchmarks, which kerf doesn't try to match by design — no compiler. -
No virtual DOM, no compiler. JSX → HTML strings → native diff. DevTools shows the real DOM because it is the DOM.
-
Values bind, structure re-renders. Hand a signal itself into a JSX hole —
class={selectedId}— and kerf binds that one node: on change, only that attribute updates, with no render re-run and no list reconcile. Moving selection between rows in a 10,000-row table touches at most the old and new row classes. (more →) -
Focus, selection, and listeners survive re-renders — even mid-list. The reconciler morphs instead of rebuilding, so caret position, IME composition, scroll, and delegated listeners survive every update; keyed rows are patched in place rather than recreated.
-
A first-party UI layer when you want one.
@kerfjs/uiadds accessible components, responsive application layouts, typed CSS values, and explicit wiring helpers without changing kerf's stateless component model. It is separately installable and tree-shakeable, so the core stays the core. (tour →) -
Safe by default. Text and attributes are HTML-escaped automatically, URL attributes are scheme-screened (
javascript:dropped), and inlineon*handlers are rejected outright — so untrusted data stays inert.raw()is the explicit, auditable opt-out.
Plus, nothing you don't ask for: JSX typed against the HTML standard (not React's props) · a small public API with no hooks, lifecycle, or per-instance state · tree-shakeable companion subpaths (router, list, overlay, async, …) that stay out of the core until imported · an ESLint plugin + opt-in dev warnings + create-kerf-component scaffold · plain TS/JSX/ESM that drops into esbuild / Vite / tsup — or no build at all via the html tagged template.
- Hybrid desktop apps (Tauri / Electron) — small bundle, predictable diff, debuggable runtime; ideal for the embedded webview.
- Embedded widgets — chat bubbles, comment boxes, dashboards dropped into someone else's page.
- Server-rendered apps with islands — Rails / Phoenix / Django / Hono.
mountper island;delegatesurvives turbo-frame swaps. - Admin panels & internal tools — reactivity without 200 KB of framework + state lib + router.
- Replacing jQuery — incremental migration; same delegation mental model, modern primitives.
- Prototyping — entire mental model on a postcard.
- Need a full ecosystem (router + forms + data + SSR streaming) → Next.js / Remix / SolidStart.
- Building a deeply componentized design-system app → React / Solid / Svelte.
- Need React Native / cross-platform mobile → React (Kerf + Tauri/Electron also covers many of these cases).
- Building a static site → Astro (we use it for this project's site).
- Already invested in a framework where switching cost outweighs the bundle size gain.
import { signal, computed, defineStore, mount, each, delegate } from 'kerfjs';
// 1. A signal — single piece of reactive state.
const count = signal(0);
// 2. A computed — auto-derived from other signals.
const doubled = computed(() => count.value * 2);
// 3. A store — multi-consumer state with named actions and reset semantics.
const cart = defineStore({
initial: () => ({ items: [] as { id: string; name: string }[] }),
actions: (set, get) => ({
add: (id: string, name: string) => set({ items: [...get().items, { id, name }] }),
remove: (id: string) => set({ items: get().items.filter((i) => i.id !== id) }),
}),
});
// 4. Mount JSX to a DOM element. Re-renders only when read signals change.
const root = document.getElementById('root')!;
mount(root, () => (
<div>
<h1>Cart ({cart.state.value.items.length})</h1>
<ul>
{each(cart.state.value.items, (item) => (
<li data-key={item.id}>
{item.name}
<button data-action="remove" data-id={item.id}>×</button>
</li>
))}
</ul>
<p>Doubled count: {doubled.value}</p>
</div>
));
// 5. Event delegation — one listener per event type, dispatched by data-action.
delegate(root, 'click', '[data-action="remove"]', (_e, btn) => {
cart.actions.remove((btn as HTMLElement).dataset.id!);
});The stringly-typed '[data-action="remove"]' pair above can be made rename-safe with the attr() helper — declare the attribute once and use it on both sides:
import { attr } from 'kerfjs';
const REMOVE = attr('data-action', 'remove'); // pre-escaped name/value/selector
<button {...REMOVE.attrs} data-id={item.id}>×</button>; // in JSX
delegate(root, 'click', REMOVE.selector, (_e, btn) => { /* … */ }); // in delegationInside a mount(), hand a signal itself (not its .value) into an attribute or text position and kerf wires that hole straight to the signal — the render function never re-runs and the list reconciler never walks:
const status = signal('idle');
mount(root, () => (
<div class={status}> {/* class attribute bound to the signal */}
Status: {status} {/* text node bound to the signal */}
</div>
));
status.value = 'saving'; // updates the class + the text node directly — no re-renderThe headline use is external state driving a hot spot: a selectedId moving between rows inside a 10,000-row each() list updates at most the old and new row class attributes, with no reconcile. Works in static content and inside each() rows (a row's binding is torn down with the row); outside a mount() (SSR / SafeHtml.toString()) a bound signal just snapshots its current value.
This is kerf's guiding idiom — values bind, structure re-renders: pass the signal itself wherever a hole is just a value, and read .value in the render function only where the JSX structure depends on it. A render that reads no .value runs exactly once; from then on every update is a direct write to the node it concerns. See docs/2-reactivity.md §2.9.
For lists where most updates are pointwise (single-row edits, append-to-end, selection flips on individual rows), reach for arraySignal from the kerfjs/array-signal subpath. Mutators emit typed patches that each() applies in O(patches), not O(N):
import { arraySignal } from 'kerfjs/array-signal';
const rows = arraySignal<{ id: number; label: string }>([]);
mount(root, () => (
<ul>{each(rows, (r) => <li data-key={r.id}>{r.label}</li>)}</ul>
));
rows.push({ id: 1, label: 'a' }); // 1 insert patch
rows.update(0, (r) => ({ ...r, label: 'A' })); // 1 update patch
rows.move(0, 1); // 1 move patchThe class lives in its own subpath so apps that don't need it shed ~1 KB. Reads on rows.value are tracking, so computed(() => rows.value.filter(...)) works as expected. See docs/2-reactivity.md §2.6.
mount() wraps effect() so the render re-runs on signal changes. Sometimes you have a freshly-built template and an already-populated element and you just want to reconcile them once — no subscription, no re-render loop. That's morph:
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)); // SafeHtmlSame algorithm mount() uses internally — data-morph-skip, data-morph-skip-children, data-morph-preserve, focused-input value + selection preservation, the user-agent-owned open rule (<details>, <dialog>, custom elements) all carry over. Only the live root's children are reconciled — the root's own attributes are never touched. Use it for SSR-fragment hydration, page-refresh diffs, third-party widget remounts. See docs/4-render.md §4.4.3.
"No compiler" isn't just a JSX story. The html tagged template from kerfjs/html has identical runtime semantics to JSX — escaping, boolean/nullish attribute rules, URL screening, on* rejection, fine-grained signal bindings, each() composition — with no transform, so a plain <script type="module"> on a CDN / importmap page is a complete kerf app:
<script type="module">
import { signal, mount, each } from "https://esm.sh/kerfjs@5";
import { html } from "https://esm.sh/kerfjs@5/html";
const items = signal([{ id: 1, label: "no build step" }]);
mount(
document.getElementById("app"),
() => html`
<ul>
${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}
</ul>
`,
);
</script>Nothing is self-hosted — kerfjs is on npm, so every ESM CDN (esm.sh, jsDelivr, unpkg) mirrors it automatically. esm.sh works with a direct import as shown; jsDelivr / unpkg want an importmap so the internal @preact/signals-core import resolves. Pin to a major (@5, as shown) rather than floating on latest, or an exact version (@5.0.0) for full reproducibility. Attribute names are written verbatim (class, not className), and holes are only legal in text positions or as a complete attribute value — anything ambiguous throws with an actionable message. See docs/6-jsx-runtime.md §6.11 (§6.11.1 for the full CDN / importmap recipes) — or the live-poll example, a complete app served exactly as authored: no bundler ever touches it.
The core stays tiny because the patterns every real app rebuilds live in optional, tree-shakeable subpaths — a modal you'd otherwise hand-roll (kerfjs/overlay), an async-state container with the stale-response race already solved (kerfjs/async), a debounce that composes inside the reactive graph (kerfjs/timing), and disposer groups with or without a DOM owner (kerfjs/scope). The largest is kerfjs/list — a keyed list that mounts each row individually (so a signal one row reads updates just that row) and virtualizes a long viewport, with fixed, app-declared, or measured row heights:
import { bindList, observeRowHeights } from 'kerfjs/list';
// Fixed-height windowing: only the visible rows render.
bindList(scrollEl, rows, {
key: (r) => r.id,
render: (r) => <div class="row">{r.label}</div>,
virtualize: { rowHeight: 32 },
});
// Measured heights (chat, feeds): kerf estimates, you report the real height.
const list = bindList(scrollEl, messages, {
key: (m) => m.id,
render: (m) => <div class="msg">{m.text}</div>,
virtualize: { rowHeight: { estimate: 64 } },
});
observeRowHeights(list); // one ResizeObserver → kerf anchor-corrects scrollAnd a whole client-side router in one call — kerfjs/router, the "postcard router." A route table, a keyed outlet(), and automatic <a href> interception; the core stays router-free (this is opt-in):
import { createRouter } from 'kerfjs/router';
const router = createRouter({
routes: [
{ path: '/', component: () => <Home /> },
{ path: '/users/:id', component: ({ id }) => <User id={id} /> },
{ path: '*', component: () => <NotFound /> },
],
});
mount(app, () => <div><nav>{/* <a href> links, auto-intercepted */}</nav>{router.outlet()}</div>);router.route is a signal; router.outlet() swaps the page wholesale across routes and morphs in place within one. Deliberately small — no nested layouts, loaders, or guards; compose those with the primitives above.
Each subpath adds nothing to the main barrel until it's imported. See docs/8-api-reference.md for the full list (list, router, overlay, scope, async, timing, remount, attach, actions).
@kerfjs/ui is the optional component layer for applications that want a
coherent interface vocabulary without switching frameworks. Components return
Kerf SafeHtml; the application still owns state and wires behavior explicitly.
Component subpaths pull in only their reachable CSS, while responsive app
layouts, typed CSS-value builders, Web Awesome adapters, and the checked
component catalog stay opt-in.
npm install kerfjs @kerfjs/uiimport { mount } from "kerfjs";
import { ListItem } from "@kerfjs/ui/list-item";
import { Toolbar } from "@kerfjs/ui/toolbar";
import { ToolbarText } from "@kerfjs/ui/toolbar-text";
const root = document.getElementById("app")!;
mount(root, () => (
<>
<Toolbar label="Files" leading={<ToolbarText text="Workspace" />} />
<ListItem action="open-file" label="src/main.ts" selected />
</>
));Start with the component selection matrix,
or read the complete @kerfjs/ui package guide.
Install and JSX setup are in Quick start above. These companion packages are opt-in.
A companion ESLint plugin enforces kerf's hard rules at edit time. Its eight core rules cover inline JSX event handlers, missing data-key in each(), nested mount(), delegate-disposer capture, attr() selector rename-safety, raw() XSS audit trails, JSX augmentation, and AI-assistant config hygiene. Six additional UI rules check component ownership, composition, CSS values, application preferences, wiring, and public boundaries when you opt into the UI preset. The plugin requires no TypeScript parser service.
npm install --save-dev eslint-plugin-kerfjs// eslint.config.js (flat config, ESLint v9+)
import kerfjs from "eslint-plugin-kerfjs";
export default [kerfjs.configs.recommended];Full docs at brianwestphal.github.io/kerf/docs/eslint-plugin/ — flat config, per-rule examples, and the rationale for which violations get lint rules vs. dev-warns vs. strict TS.
Building a reusable component package? Scaffold one that already follows kerf's hard packaging rules (kerfjs as a peer dependency and external in the build, ESM + .d.ts, jsxImportSource: "kerfjs", subpath exports), includes an example component showing per-instance state via a factory and a wire(root) delegation disposer, and generates checked package-qualified AI component metadata from explicit author decisions:
npm create kerf-component@latest my-widgetsSee docs/13-component-packages.md for the full authoring guide.
- Site: brianwestphal.github.io/kerf
- Docs:
docs/— overview · reactivity · stores · render · events · jsx · svg · API reference - Migrating: coming from another framework? — side-by-side TodoMVC translations + per-framework gotchas
- AI guide:
docs/ai/usage-guide.md— reference for AI tools fetching kerf docs (linked fromllms.txt) - UI package:
@kerfjs/ui— accessible components, responsive app layouts, typed CSS values, Web Awesome adapters, and checked AI-facing component metadata - ESLint plugin: brianwestphal.github.io/kerf/docs/eslint-plugin/ —
eslint-plugin-kerfjs; eight core rules plus six opt-in UI rules at edit time (source:eslint-plugin/) - Component scaffold:
npm create kerf-component@latest <dir>—create-kerf-component; generates a publishable component package with packaging rules plus deterministic, drift-checked AI metadata pre-wired (source:create-kerf-component/) - Demo: live demo — nine sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture,
arraySignalpatches, fine-grained signal bindings) - Repo: github.com/brianwestphal/kerf
A kerf is the narrow strip of material a saw blade removes when cutting — the smallest possible cut. The framework's job is the same: apply the smallest possible mutation to update your DOM.
(And yes, kerformance → performance jokes were written. They were also rejected.)
Stable — the public API follows semver. See CHANGELOG.md for the current version and what's shipped.
If kerf saves you time on a project you ship, sponsoring on GitHub keeps it actively maintained. Any amount is appreciated.
MIT