Skip to content

brianwestphal/kerf

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

129 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Kerf logo

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

  1. Small bundle. ~11 KB minified + gzipped including @preact/signals-core (~12 KB with arraySignal). 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 wins the compiler-driven select row and partial update benchmarks.

  2. No virtual DOM, no compiler. JSX → HTML strings → native diff. DevTools shows the real DOM because it is the DOM.

  3. Focus, selection, listeners survive re-renders. The reconciler morphs instead of rebuilding — caret position, selection range, and delegated listeners survive every re-render.

  4. Small public API. ~16 exports total. No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.

  5. Plain TS, plain JSX, plain ESM. Drops into anything using esbuild / Vite / tsup. No plugin chain.

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. mount per island; delegate survives 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. Four AST rules catch hard-rule violations — inline JSX event handlers, missing data-key in each(), nested mount(), and global JSX.IntrinsicElements augmentation. Two additional rules cover 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.

Links

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, kerformanceperformance 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

About

Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors