Kerf
The smallest cut.
Introducing Kerf. The smallest cut.
~11 KB. No virtual DOM. No compiler. No magic. Reactive UI that touches only the bytes that changed.
import { signal, mount } from 'kerfjs';
const count = signal(0);
mount(document.getElementById('app')!, () => (
<div>
<button data-action="inc">+</button>
<span>{count.value}</span>
</div>
));
That's it. Your JSX renders to HTML strings, kerf's native diff applies the minimum DOM mutations to make the live tree match, and signals re-run the render only when something they read actually changed.
Why Kerf
Small bundle. ~11 KB minified + gzipped including
@preact/signals-core(~12 KB witharraySignal). One runtime dependency. No virtual DOM, no scheduler, no concurrent-mode machinery. On the krausest js-framework-benchmark kerf is in the same cluster as Vue, vanjs, and Lit on most operations; Solid's compiler leads the update-path benchmarks (notablypartial update), 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.
Focus, selection, listeners survive re-renders — even mid-list. The reconciler morphs instead of rebuilding, so caret position, selection range, IME composition, and delegated listeners survive every re-render. Keyed lists get the same treatment: same-identity rows are updated in place rather than recreated, so a row reorder or a single-cell edit no longer blows away focus, scroll, or an in-flight animation the way node replacement does.
Small public API. ~17 exports from the main barrel (plus
arraySignalon its own subpath). No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.Plain TS, plain JSX, plain ESM. Drops into anything using esbuild / Vite / tsup. No plugin chain.
Grown-up tooling around a tiny core. An ESLint plugin that enforces the hard rules at edit time, a
create-kerf-componentscaffold for publishable component packages, drop-in AI-assistant configs, and side-by-side migration guides for a dozen-plus frameworks — none of which grows the core runtime past ~11 KB.
When to use Kerf
- 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.
When to reach for something else
- Need a full ecosystem (router + forms + data + SSR streaming) → Next.js / Remix / SolidStart.
- Building a deeply componentised 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.
Quick tour
import { signal, computed, effect, 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>
{item.name}
<button data-action="remove" data-id={item.id}>×</button>
</li>
),
(item) => item.id,
)}
</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!);
});
Long keyed lists: arraySignal
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 patch
The 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.
One-shot reconcile: morph
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';
morph(liveCard, freshlyBuiltCardEl); // Element template
morph(liveCard, '<article class="card">…</article>'); // raw HTML string
morph(liveCard, raw(htmlFromServer)); // SafeHtml
Same algorithm mount() uses internally — data-morph-skip, data-morph-skip-children, data-morph-preserve, focused-input value + selection preservation, the <details> / <dialog> user-agent-owned open rule all carry over. Use it for SSR-fragment hydration, page-refresh diffs, third-party widget remounts. See docs/4-render.md §4.4.3.
Install
npm install kerfjs
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "kerfjs"
}
}
Optional: eslint-plugin-kerfjs
A companion ESLint plugin enforces kerf's hard rules at edit time. Eight rules total: four error-level AST rules catch hard-rule violations — inline JSX event handlers, missing data-key in each(), nested mount(), and global JSX.IntrinsicElements augmentation — and four warn-level rules cover delegate-disposer capture, attr() selector rename-safety, raw() XSS audit trails, and AI-assistant config hygiene. The plugin is AST-only (no parser-services dependency), so it works with any TypeScript-ESLint setup.
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/ — legacy .eslintrc config, per-rule examples, and the rationale for which violations get lint rules vs. dev-warns vs. strict TS.
Optional: create-kerf-component
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) plus an example component showing per-instance state via a factory and a wire(root) delegation disposer:
npm create kerf-component@latest my-widgets
See docs/13-component-packages.md for the full authoring guide.
Links
- 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) - ESLint plugin: brianwestphal.github.io/kerf/docs/eslint-plugin/ —
eslint-plugin-kerfjs; eight rules (four hard-rule errors + four warns:require-delegate-disposer,prefer-attr-selector,no-raw-with-dynamic-arg,ai-assistant-configs) at edit time (source:eslint-plugin/) - Component scaffold:
npm create kerf-component@latest <dir>—create-kerf-component; generates a publishable component package with the hard packaging rules pre-wired (source:create-kerf-component/) - Demo: live demo — eight sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture,
arraySignalpatches) - Repo: github.com/brianwestphal/kerf
Why "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.)
Status
Pre-1.0 — API may evolve. See CHANGELOG.md for the current version and what's shipped.
Sponsor
If kerf saves you time on a project you ship, sponsoring on GitHub keeps it actively maintained. Any amount is appreciated.
License
MIT