Skip to content

Building reusable component packages

kerf has no component runtime — a “component” is just a function that takes props and returns SafeHtml. That makes shipping reusable components as npm packages straightforward: a package exports plain functions, the consumer imports and renders them like any local component. Nothing in the runtime prevents this, and no extra build step is required beyond what a kerf app already has.

This doc covers how to author such a package, the considerations that are unique to kerf’s no-instance model (state, events, cleanup, library-owned DOM), and how to set up and publish the package.

Quick start: scaffold with create-kerf-component

Section titled “Quick start: scaffold with create-kerf-component”

The fastest way to a correctly-configured package is the create-kerf-component initializer — it generates a package that already follows every rule below (kerfjs as a peer dependency and external in the build, ESM + .d.ts output, jsxImportSource: "kerfjs", subpath exports) plus an example component that shows the per-instance-state and wire(root)-disposer patterns and a stylesheet that owns only its component:

Terminal window
npm create kerf-component@latest my-widgets
cd my-widgets
npm install
npm run build # tsup → ESM + .d.ts; kerfjs stays external
npm run check:styles # each component styles only itself

Pass . to scaffold into the current directory. The rest of this doc explains why the generated package is shaped the way it is — read on if you’re authoring by hand or want to understand the rules the scaffold encodes.

A component is a function (props) => SafeHtml. The JSX runtime calls it directly when it sees a function-valued tag (if (typeof tag === 'function') return tag(props)), inlining the returned SafeHtml into the parent markup.

// my-button.tsx — in your component package
import type { SafeHtml } from "kerfjs";
export interface ButtonProps {
label: string;
/** A delegation hook, NOT an inline handler — see Events and cleanup. */
action: string;
variant?: "primary" | "ghost";
}
export function Button({
label,
action,
variant = "primary",
}: ButtonProps): SafeHtml {
return (
<button class={`kbtn kbtn-${variant}`} data-action={action}>
{label}
</button>
);
}

The consumer renders it the same way they’d use a local function:

import { Button } from "my-kerf-buttons";
mount(root, () => (
<div>
<Button label="Add" action="add" />
<Button label="Reset" action="reset" variant="ghost" />
</div>
));

There is no instance, no lifecycle, and no per-component state — the function runs on every render of the host mount(). Everything a component “remembers” must live outside it (see State: the one thing to get right).

Because a component is a plain function, any state must live outside it — in a signal or store. The trap is module scope: a signal declared at the top of a component module is a singleton, shared by every render and every consumer of that module.

// ❌ Shared across ALL <Counter /> instances and ALL apps that import this.
import { signal } from "kerfjs";
const count = signal(0);
export function Counter() {
return <span>{count.value}</span>;
}

That is correct for genuinely global state (a theme toggle, a toast queue) and wrong for anything that should be per-instance. For per-instance state, export a factory that creates the state and have the component read it from props:

import { defineStore, type SafeHtml } from "kerfjs";
export function createCounter(start = 0) {
return defineStore({
initial: () => ({ count: start }),
// `actions` is a builder `(set, get) => ({...})` — not an object of reducers.
actions: (set, get) => ({ inc: () => set({ count: get().count + 1 }) }),
});
}
export function Counter({
store,
}: {
store: ReturnType<typeof createCounter>;
}): SafeHtml {
// `state` is one ReadonlySignal<TState>, so read `state.value.count`.
return <span data-action="counter:inc">{store.state.value.count}</span>;
}
// Consumer — two independent counters.
const a = createCounter(0);
const b = createCounter(100);
mount(root, () => (
<>
<Counter store={a} />
<Counter store={b} />
</>
));

The rule of thumb: a reusable component should never own per-instance mutable module state. Accept signals/stores via props, or hand the consumer a factory.

Components are pure string-builders, so they can’t attach listeners or register an effect() and clean it up themselves — there is no lifecycle hook to run teardown. Two patterns cover the cases:

  1. Markup + delegation (preferred for most components). The component emits stable hooks (data-action, a class, an id) and the host wires events at the mount() root with delegate(), which returns a disposer. This survives re-renders because the listener lives on the root, not on the (re-rendered) component nodes. Never use inline JSX event handlers (onClick={...}) — they don’t survive the morph, and the no-inline-jsx-event-handlers lint rule flags them.

    If your component needs its own wiring, export a companion that the consumer calls once and disposes:

    import { delegate } from "kerfjs";
    /** Returns a disposer — call it on teardown. */
    export function wireButtons(
    root: HTMLElement,
    onAction: (a: string) => void,
    ) {
    return delegate(root, "click", "[data-action]", (e, el) =>
    onAction(el.getAttribute("data-action")!),
    );
    }
  2. Imperative widget (for wrapping third-party libraries). When the component owns a subtree kerf must not touch — a chart, an editor, a map — render an empty host marked data-morph-skip and expose a create/dispose pair:

    export function ChartHost(): SafeHtml {
    return <div class="kerf-chart" data-morph-skip />;
    }
    export function mountChart(hostEl: Element, data: number[]) {
    const chart = new ThirdPartyChart(hostEl, data);
    return () => chart.destroy(); // disposer
    }

    See the render doc for the full data-morph-skip / data-morph-skip-children / data-morph-preserve semantics — note that signal-reactive JSX placed directly inside a data-morph-skip host stops updating, which is exactly why imperative widgets manage their own DOM.

The single most important rule: declare kerfjs (and any other shared runtime) as a peerDependency, and never bundle it into your package. A component returns SafeHtml and reads signals; both rely on the consumer and your package agreeing on one SafeHtml class and one signals instance. If your package bundled its own copy of kerfjs, brand checks like isSafeHtml and signal identity would silently break across the boundary. Keep kerfjs external.

A minimal package.json:

{
"name": "my-kerf-buttons",
"version": "0.1.0",
"type": "module",
"license": "MIT",
"peerDependencies": { "kerfjs": "^5.0.0" },
"devDependencies": { "kerfjs": "^5.0.0", "tsup": "^8", "typescript": "^5" },
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
},
"files": ["dist", "README.md", "LICENSE"],
"scripts": {
"build": "tsup src/index.ts --format esm --dts --external kerfjs",
},
}

The package’s own tsconfig.json needs the same JSX wiring any kerf app uses, so the author’s .tsx compiles against kerf’s runtime:

{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "kerfjs" } }

Consumers need no extra setup. Your package ships compiled JS whose internal JSX is already lowered to kerfjs/jsx-runtime calls. A consumer who already has a working kerf app (jsxImportSource: "kerfjs") can import { Button } from 'my-kerf-buttons' and use it immediately — there’s no component-specific toolchain to install.

Components own their styles and are configured, never overridden — the rule @kerfjs/ui follows internally, applied to your package:

  • A component’s stylesheet styles only that component. counter.css selects .kerf-counter and Counter’s own internals, never another component’s class, [data-component] root, wa-* element, or ::part().
  • Configuration, not overrides. Consumers change a component’s look through typed props (the scaffold’s size="compact" becomes data-size="compact") and documented public tokens (--kerf-counter-gap), never by selecting .kerf-counter from their own CSS. Private variables are named after their component (--_kerf-counter-*) and are nobody else’s to write.
  • A child styles itself in a parent’s context. When a component must adapt inside another one — yours or a Kerf UI component — the child does it in its own stylesheet, with the parent only as an ancestor (.kui-toolbar .kerf-counter { … } lives in counter.css). A parent never styles a composed child, and never places a hook class on the child’s root to restyle it.
  • Context is named after its provider. A value a component hands its descendants is a custom property named after the component providing it (Counter gives its buttons --_kerf-counter-control-size).
  • A missing prop is a component gap. When a consumer needs a variation no prop offers, add the prop (or ask the owning package to); do not document an override.

The scaffold’s npm run check:styles runs kerf-ui-analyze over src/, and prepublishOnly runs it before every publish. It reports a rule whose subject is another package’s component (KUI-L019), another component’s private --_* variable (KUI-L020), an override of a token a typed prop sets (KUI-L021), and a hook class on a component’s root (KUI-L022), while your own component styled in a Kerf UI parent’s context passes. Applications that list your generated component-composition.json in their .kerf-ui-profile.json get the same diagnostics for your components, pointing at your props and at your package for a component-gap report.

kerf’s development diagnostics are installed, not inferred: an app writes if (import.meta.env.DEV) await import('kerfjs/dev') in its own entry, gated on its own build’s dev flag (see Dev-mode warnings). Those hooks are process-global — installing them changes defineStore’s get() to return a read-only proxy and makes a screened URL throw rather than warn, for the whole application.

That decision belongs to the app. A package that imports the dev entry forces the diagnostics on every consumer, including in production, where it also drags ~4.7 KB min+gzip into a bundle the consumer thought was dev-free. Keep the import out of src/ entirely — put it in your own demo page or test harness, which is where you want the diagnostics while developing the package anyway.

A component package builds with tsup, emits ESM + .d.ts, and publishes with npm provenance from a git tag, gated on an npm Trusted-Publisher environment. There is no npm org/scope requirement — publish unscoped.

  • Components are functions (props) => SafeHtml; no inline event handlers.
  • No per-instance state in module scope — accept signals/stores via props, or export a factory.
  • kerfjs is a peerDependency and is external in the build (never bundled).
  • Events go through delegate() at the host root, or a companion wire(root) that returns a disposer.
  • Library-owned subtrees use data-morph-skip plus a create/dispose pair.
  • Build emits ESM + .d.ts; tsconfig sets jsxImportSource: "kerfjs".
  • Each stylesheet styles only its own component, consumers configure it through props and public tokens, and npm run check:styles (kerf-ui-analyze) passes.
  • src/ never imports kerfjs/dev — installing the diagnostics is the consuming app’s call; keep it in your demo/test harness.