Kensington
GitHub

Kensington

HTML/SVG/MathML template library for JavaScript and TypeScript. Tags, attribute names/values, inline style property names, and some nested tags are comprehensively typed against the official specs. Components are plain functions with no JSX, no compiler, and very little to learn.

Reactive data via built-in signals.

npm install kensington

Quick start

Components are plain functions. Call .toString() to get an HTML string for server rendering or static generation.

JavaScript
import { t } from 'kensington';
function profileCard(name, title) {
  return t.article({ class: 'profile' },
    t.h2(name),
    t.p({ class: 'title' }, title),
    t.a({ href: `/users/${name.toLowerCase()}` }, 'View profile'),
  );
}
profileCard('Alice', 'Senior Engineer').toString();
Output
<article class="profile">
  <h2>Alice</h2>
  <p class="title">Senior Engineer</p>
  <a href="/users/alice">View profile</a>
</article>

For live DOM, call .toElement() instead. Pass a signal() anywhere a static value is accepted and the DOM updates automatically when the value changes.

JavaScript
import { t, signal, computed } from 'kensington';
const count = signal(0);
const label = computed(() => count.get() === 1 ? 'item' : 'items');
const counter = t.div([
  t.p([count, ' ', label]),
  t.button({ onclick: () => count.set(n => n + 1) }, '+'),
  t.button({ onclick: () => count.set(n => n - 1) }, '-'),
]).toElement();
document.body.append(counter);
TypeScript
import { t, signal, computed, Signal } from 'kensington';
const count: Signal<number> = signal(0);
const label = computed(() => count.get() === 1 ? 'item' : 'items');
const counter = t.div([
  t.p([count, ' ', label]),
  t.button({ onclick: () => count.set(n => n + 1) }, '+'),
  t.button({ onclick: () => count.set(n => n - 1) }, '-'),
]).toElement();
document.body.append(counter);

Signals work as content, as attribute values, inside style objects, and in the prop key. Calling .toElement() wires up all updates.

Two output modes, one codebase

.toString() renders an HTML string for server-side rendering or static generation. .toElement() builds a live DOM element and wires signal subscriptions automatically. The same tag instance works both ways.

Why Kensington?

We wanted flying cars , instead we got 140 thousand hours of React tutorials. We find ourselves spending more time trying to parse framework docs, and less time actually writing code. Kensington hopes to alleviate some of these ails by presenting a simpler alternative to the frameworks. The basics can be learned in a few minutes, and the entire API can be learned in half an hour. Kensington handles the structural work automatically, leaving a simple api for a dev to parse.

There are no magic attributes to memorize, and no new HTML templating language to learn. It is plain JavaScript function and method calls. If you can read the code, you can guess what it does, even before reading the docs.

Comprehensive typing, lint rules, IDE plugins, and server integrations help keep your code clean. In case you let robots write your code, Kensington is very AI-friendly, and produces code that is simple enough to be reviewable by a human.

Building HTML

Elements & content

Every HTML, SVG, and MathML element is available as a method on t . All call forms work:

t.div({ id: 'app' }, 'text');      // options + content
t.div({ id: 'app' });              // options only
t.div('text');                     // content only
t.div([t.p('a'), t.p('b')]);       // content array
t.div();                           // empty

Void elements take only options (no content):

t.input({ type: 'checkbox', checked: true });
t.br();
t.meta({ charset: 'utf-8' });

Content can be strings, numbers, tags, arrays, or any mix. Arrays are flattened:

t.p(['Count: ', 42, t.strong(' items')]).toString();
// <p>Count: 42<strong> items</strong></p>

Attributes accept camelCase keys (converted to kebab-case), class as an array, and style as a plain object. Boolean attributes are included when true , omitted when false .

t.div({ id: 'app', class: ['card', 'shadow'] });         // class as array
t.input({ type: 'checkbox', checked: true });            // boolean attribute
t.p({ style: { color: 'red' } }, 'Warning');             // style object
t.div({ dataBsToggle: 'collapse' });                     // camelCase → data-bs-toggle

For the full reference including nested objects, data-* , aria-* , event handlers, and DOM properties, see Attributes & options in the Advanced section.

Rendering lists

Pass an array anywhere content is expected. Each element is rendered in sequence, so .map() is the natural way to render a list of items.

JavaScript
const items = ['Apples', 'Oranges', 'Pears'];
t.ul(items.map(item => t.li(item)));
// Nested: combine with objects
t.tbody(rows.map(row =>
  t.tr([t.td(row.name), t.td(row.role)])
));
HTML output
<ul>
  <li>Apples</li>
  <li>Oranges</li>
  <li>Pears</li>
</ul>

Todo list example → Form from schema example →

Conditionals

Falsy values ( null , undefined , false , '' ) are silently dropped from content. No conditional wrappers needed.

t.ul([
  t.li('always shown'),
  isLoggedIn && t.li(t.a({ href: '/logout' }, 'Log out')),
  show ? t.li('yes') : null,
]);

If isLoggedIn is false, the second li is simply absent from the output. The null in the third slot is dropped the same way.

Live filter example →

Components & reuse

Plain functions work as components. No framework, no lifecycle, no magic. A component is just a function that takes arguments and returns a tag.

JavaScript
function card(heading, body) {
  return t.div({ class: 'card' }, [
    t.div({ class: 'card-header' }, heading),
    t.div({ class: 'card-body' }, body),
  ]);
}
t.div({ class: 'card-grid' }, [
  card('Alice', t.p('Role: Admin')),
  card('Bob',   t.p('Role: Editor')),
]);
HTML output
<div class="card-grid">
  <div class="card">
    <div class="card-header">Alice</div>
    <div class="card-body">
      <p>Role: Admin</p>
    </div>
  </div>
  <div class="card">
    <div class="card-header">Bob</div>
    <div class="card-body">
      <p>Role: Editor</p>
    </div>
  </div>
</div>

Because .toString() and .toElement() are just methods, the same component works in Node and in the browser with no changes.

Server rendering example → Express render helper example → Framework integration example → Elysia example →

Tag methods are bound to the instance, so you can destructure them and call them directly:

const { div, p, ul, li, span } = t;
div({ class: 'card' }, [
  p('Methods are bound, so destructuring works anywhere.'),
  ul([li('item one'), li('item two')]),
]);

Browser DOM

Standard event handler attributes ( onclick , oninput , etc.) accept a function (wired via addEventListener ) or a string (set via setAttribute ). For custom events, use the on key with a plain object mapping event names verbatim to handlers. SVG and MathML elements get the correct namespace automatically.

import { t } from 'kensington';
const button = t.button({ type: 'button' }, 'Click me').toElement();
document.body.append(button);
// custom events: names are passed verbatim to addEventListener
const el = t.div({
  on: {
    bricksSelectorChange: e => console.log(e.detail),
    'my-custom-event':    e => console.log(e.detail),
  },
}).toElement();
// SVG gets the correct SVGElement namespace
const svg = t.svg({ viewBox: '0 0 100 100' }, [
  t.circle({ cx: 50, cy: 50, r: 40, fill: 'steelblue' }),
]).toElement();
document.body.append(svg);

Use .toElement() to get a live DOM element. It is safe to call before the element is mounted.

Counter example →

Reactive Data

Wrap any value in signal() and pass it into a tag. When the signal changes, only the affected text node or attribute updates in place. Nothing re-renders.

import { t, signal } from 'kensington';
const count = signal(0);
document.body.append(
  t.div([
    t.p(['Count: ', count]),
    t.button(
      { onclick: () => count.set(count.value + 1) },
      '+1'
    ),
  ]).toElement()
);
Live result

Count: 0

signal(value)
Creates a reactive value. Pass it anywhere a static value is accepted — content, attributes, or DOM properties — and the DOM updates automatically when the value changes.
computed(fn)
Derives a new value from other signals. Use it for calculated state that depends on reactive data. Stays in sync automatically whenever its dependencies change.
effect(fn)
Runs a callback whenever the signals it reads change. Use it for side effects outside the DOM — page title, localStorage, analytics, or any imperative update.

Full reactivity guide: computed, effects, lifecycles, server rendering →

TypeScript

Attribute types

Types are generated directly from the HTML, SVG, and MathML living standards. Attribute names are checked, attribute values are typed as enums, booleans, or numbers as appropriate, and the style object is typed with csstype . You get a compile-time error when a value is wrong.

Content model

Strict containers enforce which children are valid at compile time. Passing a div to t.tr() is a type error. Branded return types ( TdTag , LiTag , ImgTag , etc.) extend ContentTag , so existing code that types values as ContentTag still works.

TypeScript types are also generated for custom elements and module augmentation. See Custom elements in the Advanced section.

Tooling

HTML → Kensington

The kensington CLI converts existing HTML to Kensington code. Paste it in the terminal, pipe a file, or pass a filename.

HTML input
<nav class="navbar" aria-label="main" aria-expanded="true">
  <a href="/" class="nav-link">Home</a>
  <a href="/about" class="nav-link">About</a>
</nav>
Kensington output
t.nav({ class: "navbar", aria: { label: "main", expanded: "true" } }, [
  t.a({ href: "/", class: "nav-link" }, "Home"),
  t.a({ href: "/about", class: "nav-link" }, "About"),
])
Mode Command
Interactive npx kensington (paste in the terminal)
File npx kensington index.html
Pipe echo '<p>hello</p>' | npx kensington
Redirect npx kensington < page.html
Flag Description
--copy , -c Copy output to clipboard
--help , -h Print usage

If ESLint or Prettier is present in the working directory, the converter runs the formatter over the output.

IDE plugins

CSS class completions and diagnostics inside Kensington class strings. Both plugins read your local stylesheets and any CDN stylesheets linked via t.link in your project.

Completions
Diagnostics

Available for VS Code and JetBrains IDEs. Both plugins also wire up Go to Definition and Find Usages between CSS selectors and Kensington templates.

ESLint plugin

kensington-eslint-plugin catches common signal mistakes at lint time: writes inside computed derivations, orphaned effects, async pitfalls, and more. Requires ESLint 9+ and Node 18+.

npm install --save-dev kensington-eslint-plugin

Add the recommended config to your eslint.config.js :

import kensington from 'kensington-eslint-plugin';
export default [
  kensington.configs.recommended,
  // ...your other configs
];
Error
Warning

DevTools panel

import 'kensington/devtools';

A floating overlay that tracks every signal, effect, and DOM binding live. Click the K badge in the bottom-right corner to open it. Guard the import so it does not run in production. See Devtools on the Reactive data page for setup options and a full tab reference.

Server packages

Drop-in view rendering for Express and Fastify. Each package attaches a renderView() method to the response that applies a layout, merges locals, and sends the HTML string. See the kensington-express and kensington-fastify examples for full usage.

Package Description
kensington-express Express middleware adding res.renderView(pageRenderer, options?)
kensington-fastify Fastify plugin adding reply.renderView(pageRenderer, options?)

AI assistants

Kensington ships an AGENTS.md file at the package root. It is a compact, single-file reference of the full API: method signatures, attribute rules, constructor options, TypeScript types, the CLI, and working examples. AI coding assistants can read it to answer questions and generate accurate Kensington code.

Using it

Most AI editors and assistants let you add files as context. Point yours at AGENTS.md and it will have everything it needs to work with Kensington correctly:

Advanced Usage

The above usage may be enough for many projects, but if you are building a more complex app, you may need these tools.

Attributes & options

camelCase keys { dataBsToggle: 'collapse' }data-bs-toggle="collapse" . SVG attributes like viewBox and gradientUnits pass through unchanged.
Nested objects { data: { bs: { toggle: 'collapse' } } }data-bs-toggle="collapse"
Boolean attributes { checked: true }checked . { checked: false } → attribute omitted.
class as array { class: ['foo', 'bar'] }class="foo bar"
data-* and aria-* Always allowed on every element, along with all global HTML attributes .
style as object

{ style: { backgroundColor: 'red', zIndex: 2 } }style="background-color: red; z-index: 2"

camelCase keys always convert to kebab-case. CSS property names are always kebab-case (including for SVG); camelCase is only the JavaScript DOM convention for element.style . null , undefined , and false values are silently omitted. In TypeScript, the style object is typed with csstype for autocomplete on property names and values.

on key { on: { myCustomEvent: handler } } wires listeners via addEventListener . Event names are passed verbatim. Use this for custom or camelCase event names that on* attributes cannot express. Silently ignored in .toString() .
prop key { prop: { value: 'hello' } } assigns directly to DOM properties ( el.value = ... ) instead of setAttribute . Silently ignored in .toString() .

Dev vs production

Two settings are worth flipping between local development and production. Use them together to catch attribute typos and bad values during development while shipping a small bundle to users.

Validation in development

By default, validationLevel is 'off' . In development, set it to 'warn' or 'error' so invalid attribute names and values are reported at runtime instead of silently rendering. TypeScript catches most issues at compile time. This catches the rest (dynamic attribute names, JS callers, and any code path TypeScript can't reach).

import Kensington from 'kensington';
const t = new Kensington({ validationLevel: 'error' });
t.input({ type: 'checkbox' });   // fine
t.input({ type: 'notatype' });   // throws. Not an allowed value
t.div({ unknownAttr: 'x' });     // throws. Not a known attribute

See Validation below for the full options and behavior.

Slim build for production

The slim build is a separate bundle that ships without per-element attribute spec data. The minified output drops from ~148 KB to ~27 KB, about 5× smaller. The public API is identical. Tags, attributes, signals, and hydration all work the same.

Since the slim build has no spec data, runtime validation is unavailable. The constructor throws if you set validationLevel to anything other than 'off' .

import Kensington from 'kensington/dist/slim';
const t = new Kensington();   // validationLevel defaults to 'off'
t.div({ class: 'card' }, t.p('Hello'));

Wiring it up with Vite

Use a Vite alias to swap the import target by build mode. Your application code stays as import Kensington from 'kensington' everywhere. Vite resolves to the full build in dev and the slim build in production.

// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
  resolve: {
    alias: mode === 'production'
      ? { kensington: 'kensington/dist/slim' }
      : {},
  },
}));

Pick the validation level from Vite's build environment so dev gets runtime checks and prod gets the no-op fast path.

// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: import.meta.env.DEV ? 'error' : 'off',
});

Use t everywhere in your app. npm run dev loads the full build with errors on bad attributes. npm run build produces a bundle backed by the slim runtime.

The same pattern works with other bundlers. See Rollup , esbuild , and Webpack in the examples page for equivalent setups.

Constructor options

import Kensington from 'kensington';
const t = new Kensington({
  validationLevel: 'warn',        // 'off' | 'warn' | 'error', default 'off'
  additionalNamespaces: ['hx'],   // allow hx-* (htmx), x-* (alpine), etc.
  additionalGlobalAttributes: {   // allow specific attributes on every element
    popover: ['auto', 'manual'],  // string enum
    nonce: String,                // any string value
    inert: Boolean,               // boolean attribute
  },
  indentationLevel: 2,            // spaces per indent, default 2, 0 to disable
  logger: msg => myLogger(msg),   // receives validation warnings, default console.log
});

Validation

Level Behavior
'off' No validation. Best for production. Default.
'warn' Logs via logger (default console.log ). Does not throw.
'error' Throws an Error . Useful for CI or strict development environments.
const t = new Kensington({ validationLevel: 'error' });
t.div({ class: 'ok' });         // fine
t.div({ unknownAttr: 'x' });    // throws: not a known attribute
t.input({ type: 'checkbox' });  // fine
t.input({ type: 'notatype' });  // throws: not an allowed value

Custom elements

import Kensington from 'kensington';
class MyEngine extends Kensington {
  myCard = this.createCustomTag('my-card', {
    'card-type': ['primary', 'secondary'],            // allowed string literals
    'loading': Boolean,                               // boolean attribute
    'max-items': Number,                              // numeric attribute
    'score': v => typeof v === 'number' && v <= 100,  // custom validator function
  });
}
const t = new MyEngine();
t.myCard({ 'card-type': 'primary' }, t.p('content')).toString();
// → <my-card card-type="primary">
//     <p>content</p>
//   </my-card>

To extend a built-in element with extra attributes, import its attribute object from kensington/attributes and spread it into createCustomTag :

import Kensington from 'kensington';
import { buttonAttributes } from 'kensington/attributes';
class MyEngine extends Kensington {
  button = this.createCustomTag('button', {
    ...buttonAttributes,
    popovertarget: String,  // add an attribute not yet in the spec data
  });
}
const t = new MyEngine({ validationLevel: 'error' });
t.button({ type: 'button', popovertarget: 'my-popover' }, 'Open').toString();

Every element in the spec has a corresponding named export ( divAttributes , inputAttributes , …) available from kensington/attributes .

Use ContentMethod<T> to type a custom element method, and module augmentation to allow custom attribute namespaces without a subclass:

import Kensington, { type ContentMethod } from 'kensington';
class MyEngine extends Kensington {
  myCard: ContentMethod<{ 'card-type'?: 'primary' | 'secondary'; loading?: boolean }> =
    this.createCustomTag('my-card', { 'card-type': ['primary', 'secondary'], loading: Boolean });
}
declare module 'kensington' {
  interface NameSpaceAttributes {
    [key: `hx${string}`]: string | object;
  }
}
t.div({ hxBoost: 'true' });  // now valid

htmx integration example → Tailwind example → Alpine.js example →

Persist effects

By default, .toElement() stops signal effects permanently when an element is removed from the DOM. For elements that will be moved or temporarily removed and re-inserted, add persist: true to the tag options. Effects are paused on removal and resume automatically on re-insertion, across any number of cycles.

// Without persist: true, the reconciler's insertBefore moves during drag-reorder
// trigger dom-tracker to permanently stop the item's signal effects (class, checked, etc.).
// With persist: true, effects pause on removal and resume when the node is re-inserted.
const item = t.li({ 'data-key': task.id, class: statusClass, persist: true }, [
  t.input({ type: 'checkbox', checked: task.done }),
  t.span(task.text),
]);

persist: true is silently ignored in .toString() and has no effect on server-side rendering. It only changes behavior when an element created by .toElement() is removed and re-inserted into the DOM.

Raw HTML & comments

t.literal('<li>verbatim, HTML-encoded</li>');    // <script> tags flagged via validationLevel
t.unsafeLiteral('<li>trusted HTML, no encoding</li>');
t.inlineComment('hello world');          // <!-- hello world -->
t.inlineComment('line 1\nline 2');       // <!--\n  line 1\n  line 2\n-->

Preformatted text example →

Complete examples covering SSR, htmx, forms, icon reuse, and reactive patterns are on the Examples page .

GitHub

Reactive data

Pass a signal() anywhere a static value is accepted (as an attribute value, content, or DOM property) and Kensington wires up live DOM updates automatically. When the signal changes, only the affected attribute or text node is updated in place.

Signals

A signal holds a reactive value. Read it with .get() and write it with .set() . Anything using the signal updates automatically when the value changes.

import { signal, t } from 'kensington';
const busy = signal(false);
const result = signal('Press the button to fetch a quote.');
function fetchQuote() {
  busy.set(true);
  fetch('/api/quote')
    .then(r => r.json())
    .then(data => result.set(data.text))
    .finally(() => busy.set(false));
}
document.body.append(t.div([
  t.p(result),
  t.button({ type: 'button', disabled: busy, onclick: fetchQuote }, 'Fetch quote'),
]).toElement());

Counter example →

computed

A read-only signal derived from others. Re-evaluates automatically when any dependency changes.

const firstName = signal('Ada');
const lastName = signal('Lovelace');
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
// fullName re-evaluates whenever either signal changes
t.p(fullName).toElement();

effect

Runs immediately and re-runs whenever any signal read via .get() inside it changes. Use for side effects that live outside the DOM: document.title , localStorage , analytics, etc.

const count = signal(0);
const e = effect(() => {
  // runs whenever count changes
  document.title = `${count.get()} items`;
});

Content

Pass a signal as an element's content (or anywhere in a content array) and the text node updates in place when the signal changes.

const count = signal(0);
const label = computed(() => count.get() === 1 ? 'item' : 'items');
t.p([count, ' ', label]).toElement();
count.set(3);  // renders "3 items"

A signal returning an array replaces its placeholder nodes on each change. A signal returning null or undefined renders nothing.

Attributes

Pass a signal as any attribute value. The attribute is set, removed, or toggled automatically when the signal changes.

const isLoading = signal(false);
const cls = computed(() => isLoading.get() ? 'btn-secondary' : 'btn-primary');
t.button({ class: cls, disabled: isLoading }, 'Save').toElement();
isLoading.set(true);   // disables button and changes class
isLoading.set(false);  // restores it

Character counter example → Dark mode example →

Reactive style properties

Individual properties inside a style object accept signals. Only the changed property is written to the DOM on each update — all other properties are left untouched.

const color = signal('red');
const opacity = signal(1);
t.div({
  style: {
    color,             // reactive — only color is updated when the signal changes
    opacity,           // reactive — only opacity is updated when the signal changes
    fontSize: '1rem',  // static — set once at render time
  },
}).toElement();
color.set('blue');   // writes el.style.setProperty('color', 'blue')
opacity.set(0.5);    // writes el.style.setProperty('opacity', '0.5')

A signal that resolves to null , undefined , false , or '' calls removeProperty on that property. In .toString() , all signal values are resolved to their current value inline. The style attribute also continues to accept a signal returning a whole object or string for cases where the entire style needs to change atomically.

DOM properties

Sets a property instead of an attribute. input.value reflects what the user typed, while getAttribute('value') still returns the original default. Use the prop key to assign directly to DOM properties via el[name] = value , bypassing setAttribute :

const userInput = signal('');
// Assigns el.value = '' reactively, keeping the live property in sync
t.input({ type: 'text', prop: { value: userInput } }).toElement();
// Resetting
userInput.set('');  // el.value resets immediately
// Properties with no HTML attribute equivalent
const isMuted = signal(true);
t.video({ src: '/intro.mp4', prop: { muted: isMuted, playbackRate: 1.5 } }).toElement();
isMuted.set(false); // unmutes video

Keyed lists

When a signal holds an array, add dataKey to items. The reconciler matches nodes by data-key and reuses DOM elements on reorder, addition, and removal. Reused nodes are diffed recursively: only changed attributes and text are written to the DOM. Signal-managed attributes on reused nodes are preserved, and orphaned effects on discarded nodes are stopped immediately.

const items = signal([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
]);
const rows = computed(() =>
  items.get().map(item => t.li({ dataKey: item.id }, item.name)),
);
t.ul(rows).toElement();

Sortable table example →

With .literal and .inlineComment

const html = signal('<b>bold</b>');
t.div(t.literal(html)).toElement();
// element is replaced when html changes
const note = signal('draft');
t.div([t.p('content'), t.inlineComment(note)]).toElement();
// comment nodeValue updates live

Existing elements

When most of a page is static HTML, it is simpler to reach into the DOM with querySelector and drive updates with effect() directly rather than rebuilding large chunks of markup with .toElement() .

import { signal, effect } from 'kensington';
const theme = signal('light');
// Toggle a class on a single element
const root = document.documentElement;
effect(() => {
  root.classList.toggle('dark', theme.get() === 'dark');
});
// Drive a set of elements from one signal
const currentTab = signal('overview');
document.querySelectorAll('[data-tab-content]').forEach(el => {
  effect(() => {
    el.classList.toggle('hidden', el.dataset.tabContent !== currentTab.get());
  });
});
// Update text content
const count = signal(0);
const label = document.getElementById('count-label');
effect(() => {
  label.textContent = count.get();
});

Advanced Usage

The above usage may be enough for many projects, but if you are building a more complex app, you may need these tools.

When updates fire

A signal notifies its subscribers when .set() is called with a value that differs from the current one. The check is reference-style ( Object.is ), not deep.

Value type What "differs" means
string , number , boolean , null , undefined Different value. signal.set(3) when the current value is 3 is a no-op.
Array , Object , anything else Different reference. Mutating the existing value in place does not count. You must produce a new array or object.

This is the most common source of "my signal isn't updating" confusion. The fix is to update immutably.

Immutable update patterns

The same shapes work for any reactive library and all have built-in support in modern JavaScript.

// Array: replace one item by id, keep the others
items.set(prev => prev.map(it => it.id === 5 ? { ...it, done: true } : it));
// Array: add an item
items.set(prev => [...prev, newItem]);
// Array: remove an item
items.set(prev => prev.filter(it => it.id !== 5));
// Object: change one field
user.set(prev => ({ ...prev, name: 'Ada' }));
// Nested object: change a deep field (each level needs a spread)
state.set(prev => ({
  ...prev,
  profile: { ...prev.profile, name: 'Ada' },
}));

If a field needs to update frequently and is deeply nested, give it its own signal rather than reaching for spreads on every level. See Per-row signals below.

What does not trigger an update

const items = signal([{ id: 1, label: 'a' }, { id: 2, label: 'b' }]);
// 1. Mutating an element of the array. No update.
items.get()[0].label = 'changed';
// 2. Setting the signal to the same array reference. No update.
items.set(items.get());
// 3. Mutating then re-setting with the same reference. Still no update.
items.get()[0].label = 'changed';
items.set(items.get());
// 4. In-place array methods like push, pop, shift, unshift, splice, sort, reverse.
//    All mutate the existing array. The signal isn't notified.
items.get().push({ id: 3, label: 'c' });
items.get().splice(0, 1);   // remove the first item
items.get().sort((a, b) => a.label.localeCompare(b.label));
// 5. Object.assign on an existing object. The returned value is the same target reference,
//    so even re-setting after it does nothing.
Object.assign(items.get()[0], { label: 'changed', done: true });
// 5a. Capturing the array first, mutating, then re-setting doesn't help either. `arr` is
//     the same reference as items.get(), so signal.set short-circuits via Object.is. The
//     value returned by Object.assign is also the same target reference.
const arr = items.get();
Object.assign(arr[0], { label: 'changed', done: true });
items.set(arr);                       // no update

All five patterns leave the DOM stale. The first, fourth, and fifth update internal state but never tell the signal anything happened. The second, third, and 5a get short-circuited because Object.is(items.get(), items.get()) is true regardless of whether the value was mutated in between.

Mutating helpers like splice , sort , and Object.assign are particularly easy to reach for because they look like they "update" the value. They do, but the signal doesn't know. The non-mutating forms work as expected:

// Remove an item: filter to a new array
items.set(prev => prev.filter((_, i) => i !== 0));
// Add an item: spread into a new array
items.set(prev => [...prev, { id: 3, label: 'c' }]);
// Sort: toSorted returns a new array (ES2023+, or use [...prev].sort())
items.set(prev => prev.toSorted((a, b) => a.label.localeCompare(b.label)));
// Patch fields on an item: spread the item into a new object
items.set(prev => prev.map(it =>
  it.id === 1 ? { ...it, label: 'changed', done: true } : it,
));

When the DOM actually updates

Once a signal fires, what happens to the DOM depends on where the signal is used.

Use site What updates
t.div(signal) (signal as content) The text node (or the matching set of child nodes for an array signal) is patched in place. Surrounding content is untouched.
t.input({ value: signal }) (signal as attribute) Just that attribute. setAttribute is called. Boolean attributes are added or removed.
t.input({ prop: { value: signal } }) (signal as DOM property) Just that property. element[prop] = value is called. Required for things like input.value after the user has typed into the field.
effect(() => ...) inside The effect re-runs. Multiple .set() calls in the same synchronous turn coalesce into a single re-run via microtask batching.
computed(() => ...) inside The computed re-evaluates synchronously. Its subscribers then update as above.

Per-row signals for fine-grained updates

For lists where individual rows change often, store a signal on each item rather than reactively re-rendering the entire array.

// The whole `items` array doesn't need to re-render when one row's done flag flips.
const items = signal([
  { id: 1, label: 'Buy milk', done: signal(false) },
  { id: 2, label: 'Walk dog', done: signal(true)  },
]);
function row(item) {
  return t.li(
    {
      dataKey: item.id,
      class: item.done.transform(d => d ? 'done' : 'open'),
    },
    item.label,
  );
}
const list = t.ul(items.transform(arr => arr.map(row))).toElement();
// Update one row. The parent `items` signal does not fire. Only the affected element's
// class attribute is rewritten. Adding or removing a row still uses items.set() with a
// fresh array.
items.get()[0].done.set(true);

The keyed reconciler is built for the array-set path (adding, removing, reordering rows). Per-row signals are the right tool when only a row's contents change.

.value

Use .value instead of .get() inside effect() or computed() when you need the current value of a signal without subscribing to changes:

const searchTerm   = signal('');
const previousTerm = signal('');
// Re-runs when searchTerm changes. previousTerm.value reads without subscribing.
// Using .get() would subscribe the effect to previousTerm, and the .set()
// in the callback would re-trigger the effect, firing a duplicate request.
effect(() => {
  const current = searchTerm.get();
  const previous = previousTerm.value;
  const isRefinement = current.startsWith(previous) && previous.length > 0;
  fetch(`/search?q=${current}`)
    .then(r => r.json())
    .then(data => {
      results.set(data);
      previousTerm.set(current);
    });
});

Incremental search example →

.transform

Returns a new read-only signal whose value is derived by passing the source signal's value through a function. Equivalent to computed(() => fn(source.get())) , but attached directly to the signal.

const count = signal(0);
const label = count.transform(n => n === 1 ? '1 item' : `${n} items`);
t.p(label).toElement(); // "0 items", updates when count changes
// useful for coercing a signal's type before passing it as an attribute
const sortAsc = signal(true);
t.th({ 'aria-sort': sortAsc.transform(v => v ? 'ascending' : 'descending') });

Cleanup

Elements created with .toElement() automatically stop their reactive effects when the element is removed from the DOM, whether by el.remove() or by removing an ancestor.

const count = signal(0);
const el = t.p(count).toElement();
document.body.append(el);
el.remove(); // effect tracking count stops automatically

To pause effects instead of stopping them, add persist: true to the tag options. Effects resume automatically when the element is re-inserted, and pause again if it is removed a second time. This works across unlimited cycles.

const cls = signal('idle');
const el = t.div({ class: cls, persist: true }).toElement();
document.body.append(el);
el.remove();               // effects pause. cls.set() has no DOM effect
document.body.append(el);  // effects resume
cls.set('active');         // DOM updates immediately

For effects that run outside of any element, call e.pause() to temporarily unsubscribe and e.resume() to restart. Call e.stop() when the effect is no longer needed. It permanently destroys it and resume() becomes a no-op. To tie an effect to a component's lifetime without manual bookkeeping, use addDisconnectedCallback . See Lifecycle below.

Lifecycle

Kensington tag objects support lifecycle callbacks via addConnectedCallback(fn) and addDisconnectedCallback(fn) , mirroring the web component lifecycle. Call them on a tag object before calling .toElement() . Both methods return this and can be called multiple times to register multiple handlers. Callbacks receive the live DOM element as both the first argument and as this , matching web component convention.

addConnectedCallback

Fires when the element is inserted into the DOM. Use it for initialization that requires DOM presence, such as reading layout dimensions, starting side effects that should only run while the element is mounted, or initializing third-party libraries that need a live element.

const panel = t.div({ class: 'panel' }, content);
panel.addConnectedCallback(function(el) {
  // el (and `this`) is the DOM element — layout is readable here
  const { width } = el.getBoundingClientRect();
  el.dataset.initialWidth = width;
});
document.body.append(panel.toElement()); // callback fires here

By default the callback fires once per toElement() call and is cleared when the element is removed. With persist: true in the tag options, all connected and disconnected callbacks re-fire on every cycle.

const tag = t.div({ persist: true }, content);
tag.addConnectedCallback(setup);
tag.addDisconnectedCallback(teardown);
tag.toElement();  // both callbacks re-fire on every insert/remove cycle

addDisconnectedCallback

Fires when the element leaves the DOM. Signal effects are stopped first, then disconnected callbacks run. Use it for cleanup that signals cannot handle automatically, such as clearing intervals and timers, destroying third-party library instances, or removing portal elements.

By default the callback fires once and is not re-registered. With persist: true in the tag options, all disconnect callbacks re-fire on every removal.

let intervalId;
const ticker = t.div({ class: 'ticker' }, price);
ticker.addDisconnectedCallback(() => {
  clearInterval(intervalId);
});

For a complete example combining both callbacks with an interval timer and a portal element, see the lifecycle widget on the Examples page.

Server-rendered reactive data

Server-render a component to HTML with renderForHydration , then pick it up on the client with registerComponents . The SSR output is replaced with a live, reactive DOM tree using the same state that was passed on the server.

// server.js
import { renderForHydration, t } from 'kensington';
import { counter } from './components/counter.js';
app.get('/', (req, res) => {
  res.send(
    t.htmlWithDocType({ lang: 'en' }, [
      t.head([t.meta({ charset: 'utf-8' }), t.title('App')]),
      t.body(renderForHydration(counter, { count: 0 })),
    ]).toString()
  );
});
// client.js
import { registerComponents } from 'kensington';
import { counter } from './components/counter.js';
registerComponents({ counter });

The component function runs on both server and client. Write it so it works in both environments:

// components/counter.js
import { t, signal, effect, isBrowser } from 'kensington';
export function counter({ count: initial }) {
  const count = signal(initial);
  // effect() is a no-op on the server: safe to use browser globals inside
  effect(() => {
    document.title = `Count: ${count.get()}`;
  });
  // isBrowser guards code that can't go inside effect()
  const stored = isBrowser ? localStorage.getItem('count') : null;
  return t.div([
    t.p(count),
    t.button({ type: 'button', onclick: () => count.set(n => n + 1) }, '+'),
  ]);
}
Export Context Description
renderForHydration(fn, state, name?) Server Renders the component to HTML and embeds state as a JSON script block. Uses fn.name by default server-side. Pass an explicit name for anonymous functions and when calling in the browser. Function names are not safe after minification. name must match what is used in registerComponents on the client. Throws if the component returns a non-element value or a Promise. Warns on lossy state values (Date, Map, Set, RegExp, undefined, function, Symbol, non-finite numbers, class instances); throws on unserializable ones (BigInt, circular references).
registerComponents(components) Client Scans the page for components rendered by renderForHydration and mounts each one reactively. Object keys are used as component names: { counter } registers the function under 'counter' . Must match what is passed in renderForHydration on the server. Issues a console.warn for unregistered component names and missing mount points. If the client component returns null or throws, warns or logs the error and leaves the SSR element in place. Defers hydration until DOMContentLoaded if called while the page is still loading. Sets up a MutationObserver so components in dynamically fetched HTML fragments are hydrated automatically without re-calling registerComponents . Returns { stop() } to disconnect the observer.
isBrowser Both true in a browser environment, false in Node.js. Use to guard browser-only code that cannot go inside effect() , such as module-level expressions or computed() values.

Known tradeoffs

These are deliberate simplicity choices, not bugs.

Hydrated like button example → Hydrated form validation example →

Best Practices

A few common mistakes and how to avoid them.

Use a signal for any value that needs to change after render

Attributes, content, and prop values are read once when the tag is built. A plain variable passed at that point is a snapshot — changing it later has no effect on the DOM. Wrap the value in a signal so updates flow through automatically.

// Problem: the attribute is read once at creation. Changing the variable does nothing.
let submitting = false;
const btn = t.button({ disabled: submitting }, 'Submit').toElement();
submitting = true; // button is still enabled
// Fixed: the attribute updates whenever the signal changes.
const submitting = signal(false);
const btn = t.button({ disabled: submitting }, 'Submit').toElement();
submitting.set(true); // button becomes disabled

The same applies to text content ( t.p(mySignal) ) and prop values ( prop: { value: mySignal } ).

Do not create signals or computeds inside a computed or transform callback

A computed() or transform() callback re-runs every time its dependencies change. Any signal() or computed() call inside the callback creates a brand-new instance on each re-run. These fresh instances never gain subscribers, so they go dormant immediately and leave behind orphaned entries in the devtools Signals panel. They also prevent the reconciler from reusing existing DOM nodes, because a new signal reference never matches the stored snapshot.

// Problem: a new computed is created on every re-render.
const rows = items.transform(list =>
  list.map(item => {
    const cls = computed(() => item.done ? 'done' : 'open'); // new instance each time
    return t.li({ dataKey: item.id, class: cls }, item.label);
  })
);

Create the reactive value once, when the item is first made, and store it on the item object.

// Fixed: the computed is created once per item, not once per render.
function makeItem(id, label) {
  const done = signal(false);
  const cls = done.transform(d => d ? 'done' : 'open');
  return { id, label, done, cls };
}
const items = signal([makeItem(1, 'Buy milk'), makeItem(2, 'Walk dog')]);
const rows = items.transform(list =>
  list.map(item => t.li({ dataKey: item.id, class: item.cls }, item.label))
);

Use a named function for event handlers that read mutable state

Inline arrow functions in a .map() create a new reference on every render. The reconciler sees that the function changed, touches the DOM node to swap in the new handler, and rebuilds a snapshot. That is fine, but it means every re-render does extra work for each list item.

A named function defined outside the callback has a stable reference. The reconciler sees nothing changed and skips the node entirely. Because the function reads its closed-over variables at call time rather than capturing them, it always sees the current value.

// Inline arrow: new reference each render. Works correctly but the reconciler
// touches every node to swap in the updated handler.
let mode = 'view';
const rows = items.transform(list =>
  list.map(item =>
    t.li({ dataKey: item.id, onclick: () => handleClick(item.id, mode) }, item.label)
  )
);
// Named function: stable reference. The reconciler skips unchanged nodes.
// mode is read at click time so it always reflects the current value.
let mode = 'view';
function handleClick(e) { doSomething(e.currentTarget.dataset.id, mode); }
const rows = items.transform(list =>
  list.map(item =>
    t.li({ dataKey: item.id, onclick: handleClick }, item.label)
  )
);
mode = 'edit'; // all items see 'edit' when clicked, no re-render needed

Add data-key to list items that may change

Without a key, every re-render tears down all existing list nodes and builds fresh ones. With a key, the reconciler matches old nodes to new items by ID, reuses any node whose content is unchanged, and only touches the nodes that actually changed.

// Problem: all nodes are replaced on every update, even when most items are unchanged.
const rows = items.transform(list =>
  list.map(item => t.li(item.label))
);
// Fixed: nodes are reused. Only added or removed items touch the DOM.
const rows = items.transform(list =>
  list.map(item => t.li({ dataKey: item.id }, item.label))
);

Devtools

Kensington ships a devtools overlay for inspecting signals, computed signals, effects, and DOM bindings at runtime. It is a floating panel that can be toggled with a button in the bottom-right corner of the page. A pop-out button (↗) in the panel header opens it in a separate window so it can sit alongside the page being developed. The popup reconnects automatically when the main page reloads.

Setup

Import kensington/devtools in your dev entry point. It mounts the panel overlay in one step. Wrap it in your bundler's dev-only guard so it tree-shakes out of production builds.

// Vite
if (import.meta.env.DEV) {
  await import('kensington/devtools');
}
// webpack / esbuild / Parcel
if (process.env.NODE_ENV !== 'production') {
  await import('kensington/devtools');
}
// No-build (plain script tags, import maps)
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
  await import('kensington/devtools');
}

The import is safe in non-browser environments. It checks for window before mounting and does nothing on the server.

Panel

The panel has five tabs.

The filter input in each tab narrows rows by ID, value, label, or state. Hovering a signal row highlights its bound elements on the page with a temporary outline.

GitHub

Examples

String rendering

Server-side rendering

Tag objects convert to strings automatically in template literals and string concatenation. Call .toString() explicitly when passing to a function like res.send() , which won't coerce the argument otherwise.

import express from 'express';
import { t } from 'kensington';
function layout(title, content) {
  return t.htmlWithDocType({ lang: 'en' }, [
    t.head([
      t.meta({ charset: 'utf-8' }),
      t.title(title),
      t.link({ rel: 'stylesheet', href: '/style.css' }),
    ]),
    t.body(t.main({ class: 'container' }, content)),
  ]).toString();
}
function usersPage(users) {
  return [
    t.h1('Users'),
    t.table([
      t.thead(t.tr(['Name', 'Role'].map(h => t.th(h)))),
      t.tbody(users.map(u =>
        t.tr([t.td(u.name), t.td(u.role)])
      )),
    ]),
  ];
}
const app = express();
app.get('/users', async (req, res) => {
  const users = await db.getUsers();
  res.send(layout('Users', usersPage(users)));
});

Framework integration

Kensington works with any Node.js HTTP framework. The pattern is the same everywhere: build your HTML with Kensington, call .toString() , and pass the string to the framework's response method.

Hono
import { Hono } from 'hono';
import { t } from 'kensington';
const app = new Hono();
app.get('/users', async (c) => {
  const users = await db.getUsers();
  return c.html(layout('Users', usersPage(users)));
});
Fastify
import Fastify from 'fastify';
import { t } from 'kensington';
const app = Fastify();
app.get('/users', async (req, reply) => {
  const users = await db.getUsers();
  reply
    .header('content-type', 'text/html; charset=utf-8')
    .send(layout('Users', usersPage(users)));
});

Hono's c.html() sets the content-type header automatically. For frameworks that don't have a dedicated HTML method, set Content-Type: text/html; charset=utf-8 manually as shown in the Fastify example.

Express render helper

Attach a res.renderKensington helper via middleware so routes never call .toString() directly and the layout is applied in one place.

middleware/render.js
import { layout } from './layout.js';
export function renderMiddleware(req, res, next) {
  res.renderKensington = (pageFunc, ...args) => {
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(layout(pageFunc(...args)).toString());
  };
  next();
}
server.js
import express from 'express';
import { homePage, usersPage } from './pages.js';
import { renderMiddleware } from './middleware/render.js';
const app = express();
app.use(renderMiddleware);
app.get('/', (req, res) => {
  res.renderKensington(homePage, { title: 'Home' });
});
app.get('/users', async (req, res) => {
  const users = await db.getUsers();
  res.renderKensington(usersPage, { title: 'Users', users });
});

kensington-express

kensington-express is an Express middleware that attaches res.renderView() to each response. It applies a default layout, merges locals, and sets the content-type header automatically.

npm install kensington-express
views/layout.js
import { t } from 'kensington';
export default function layout(locals, page) {
  return t.htmlWithDocType({ lang: 'en' }, [
    t.head([
      t.meta({ charset: 'utf-8' }),
      t.title(locals.title),
      t.link({ rel: 'stylesheet', href: '/style.css' }),
    ]),
    t.body(page(locals)),
  ]);
}
views/home.js
import { t } from 'kensington';
export default function homePage({ title, items }) {
  return t.main([
    t.h1(title),
    t.ul(items.map(item => t.li(item))),
  ]);
}
// app.js
import express from 'express';
import kensingtonView from 'kensington-express';
import layout from './views/layout.js';
import homePage from './views/home.js';
const app = express();
app.use(kensingtonView(layout));
app.get('/', (req, res) => {
  res.renderView(homePage, { title: 'Home', items: ['foo', 'bar'] });
});

Locals passed to renderView are merged with req.route , app.locals , and res.locals (later values win). To use a different layout for one route, pass it as layout in the options object. Pass layout: null to skip the layout entirely, which is useful for returning bare HTML fragments for htmx swap targets.

// Alternate layout for one route
app.get('/admin', (req, res) => {
  res.renderView(adminPage, { layout: adminLayout, title: 'Admin' });
});
// No layout (bare fragment)
app.get('/fragment', (req, res) => {
  res.renderView(myFragment, { layout: null });
});

kensington-fastify

kensington-fastify is a Fastify plugin that attaches reply.renderView() and decorates each reply with reply.locals for per-request data.

npm install kensington-fastify
// server.js
import Fastify from 'fastify';
import kensingtonView from 'kensington-fastify';
import layout from './views/layout.js';
import homePage from './views/home.js';
const fastify = Fastify();
await fastify.register(kensingtonView, {
  defaultLayout: layout,
  defaultContext: { appName: 'My App' },
});
fastify.get('/', async (request, reply) => {
  reply.renderView(homePage, { title: 'Home', items: ['foo', 'bar'] });
});

Locals are merged in this order (later values win): defaultContext , reply.locals , options passed to renderView . Use reply.locals in a hook to attach per-request data without passing it to every renderView call.

// Attach the current user in a hook — available in every page renderer
fastify.addHook('preHandler', async (request, reply) => {
  reply.locals.user = await getUserFromSession(request);
});
fastify.get('/', async (request, reply) => {
  reply.renderView(homePage, { title: 'Home' });
  // locals available to the renderer: { appName, user, title }
});

To use an alternate layout or skip the layout for one route, pass layout in the options object. Pass layout: null for bare HTML fragments.

// Alternate layout
fastify.get('/admin', async (request, reply) => {
  reply.renderView(adminPage, { layout: adminLayout, title: 'Admin' });
});
// No layout (bare fragment, e.g. for htmx)
fastify.get('/fragment', async (request, reply) => {
  reply.renderView(myFragment, { layout: null });
});

Form from schema

Build forms from a field definition array using a helper function.

JavaScript
const fields = [
  { name: 'email',    type: 'email',    label: 'Email',    required: true },
  { name: 'password', type: 'password', label: 'Password', required: true },
  { name: 'remember', type: 'checkbox', label: 'Remember me' },
];
function formField({ name, type, label, required }) {
  return t.div({ class: 'field' }, [
    t.label({ for: name }, label),
    t.input({ id: name, name, type, required }),
  ]);
}
t.form({ action: '/login', method: 'post' }, [
  ...fields.map(formField),
  t.button({ type: 'submit' }, 'Log in'),
]);
HTML output
<form action="/login" method="post">
  <div class="field">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required>
  </div>
  <div class="field">
    <label for="password">Password</label>
    <input id="password" name="password" type="password" required>
  </div>
  <div class="field">
    <label for="remember">Remember me</label>
    <input id="remember" name="remember" type="checkbox">
  </div>
  <button type="submit">Log in</button>
</form>

Preformatted blocks

script , style , pre , and textarea join content arrays with newlines and skip indentation, so string content is inserted without modification.

JavaScript
t.style([
  'body { margin: 0; }',
  'h1 { color: steelblue; }',
]);
t.script(`
  const el = document.getElementById("app");
  el.textContent = "Hello";
`);
HTML output
<style>
body { margin: 0; }
h1 { color: steelblue; }
</style>
<script>
  const el = document.getElementById("app");
  el.textContent = "Hello";
</script>

Reactive data

Counter

A basic counter using signal , computed , and effect . Multiple synchronous set() calls batch into a single DOM update via microtask.

import { t, signal, computed, effect } from 'kensington';
const count = signal(0);
const label = computed(() => count.get() === 1 ? 'click' : 'clicks');
effect(() => {
  document.title = `${count.get()} ${label.get()}`;
});
const app = t.div({ class: 'counter' }, [
  t.p([count, ' ', label]),
  t.button({ type: 'button', onclick: () => count.set(n => n + 1) }, '+'),
  t.button({ type: 'button', onclick: () => count.set(n => n - 1) }, '-'),
  t.button({ type: 'button', onclick: () => count.set(0) }, 'Reset'),
]);
document.body.append(app.toElement());

Live filter

A signal holds the search query. A computed signal derives the visible rows. Passing the computed signal as content means the table body updates automatically as the user types, with no manual DOM writes needed.

import { t, signal, computed } from 'kensington';
const people = [
  { name: 'Alice', role: 'Admin'  },
  { name: 'Bob',   role: 'Editor' },
  { name: 'Carol', role: 'Viewer' },
  { name: 'Dave',  role: 'Editor' },
  { name: 'Eve',   role: 'Admin'  },
];
const query = signal('');
const rows = computed(() => {
  const q = query.get().toLowerCase();
  return people
    .filter(p => !q || p.name.toLowerCase().includes(q) || p.role.toLowerCase().includes(q))
    .map(p => t.tr([t.td(p.name), t.td(p.role)]));
});
document.body.append(
  t.div([
    t.input({
      type: 'search',
      placeholder: 'Filter by name or role...',
      oninput: e => query.set(e.target.value),
    }),
    t.table([
      t.thead(t.tr([t.th('Name'), t.th('Role')])),
      t.tbody(rows),
    ]),
  ]).toElement()
);

Todo list

A signal holds the todo array. .transform() derives a signal of rendered list items. Adding dataKey to each item lets the reconciler match nodes by key on re-render, so only changed items are written to the DOM.

import { t, signal } from 'kensington';
let nextId = 1;
const todos = signal([
  { id: nextId++, text: 'Buy groceries', done: false },
]);
function addTodo(text) {
  todos.set(list => [...list, { id: nextId++, text, done: false }]);
}
function toggleTodo(id) {
  todos.set(list =>
    list.map(item => item.id === id ? { ...item, done: !item.done } : item)
  );
}
function removeTodo(id) {
  todos.set(list => list.filter(item => item.id !== id));
}
const rows = todos.transform(list =>
  list.map(item =>
    t.li({ dataKey: item.id }, [
      t.span({ style: { textDecoration: item.done ? 'line-through' : 'none' } }, item.text),
      t.button({ type: 'button', onclick: () => toggleTodo(item.id) }, 'Done'),
      t.button({ type: 'button', onclick: () => removeTodo(item.id) }, 'Remove'),
    ])
  )
);
const input = t.input({ type: 'text', placeholder: 'New item...' });
document.body.append(
  t.div([
    t.div([
      input,
      t.button({
        type: 'button',
        onclick: () => {
          const el = input.getDomElement();
          if (el?.value.trim()) { addTodo(el.value.trim()); el.value = ''; }
        },
      }, 'Add'),
    ]),
    t.ul(rows),
  ]).toElement()
);

Dark mode

effect is the right tool when a signal needs to drive something outside the reactive tree. Here it toggles a class on document.documentElement . The button label is a computed that flips with the signal.

import { t, signal, computed, effect } from 'kensington';
const dark = signal(matchMedia('(prefers-color-scheme: dark)').matches);
effect(() => {
  document.documentElement.classList.toggle('dark', dark.get());
});
const label = computed(() => dark.get() ? 'Light mode' : 'Dark mode');
document.body.append(
  t.button({ type: 'button', onclick: () => dark.set(v => !v) }, label).toElement()
);

Character counter

.transform() derives the CSS class directly from the remaining count. Passing the remaining signal as content means the number updates in place without replacing surrounding text nodes.

import { t, signal, computed } from 'kensington';
const MAX = 280;
const text = signal('');
const remaining = computed(() => MAX - text.get().length);
document.body.append(
  t.div([
    t.textarea({
      rows: 4,
      placeholder: 'Type something...',
      oninput: e => text.set(e.target.value),
    }),
    t.p({
      class: remaining.transform(n => n < 0 ? 'counter counter--over' : 'counter'),
    }, [remaining, ' characters remaining']),
  ]).toElement()
);

Sortable table

Two signals, sort column and sort direction, drive both the data rows and the column headers. Each header creates its own computed that tracks only the signals it actually reads: the active header tracks both, inactive headers track only sortCol . Stale subscriptions are cleaned up automatically between runs.

import { t, signal, computed } from 'kensington';
const people = [
  { name: 'Alice', age: 32, role: 'Admin'  },
  { name: 'Bob',   age: 28, role: 'Editor' },
  { name: 'Carol', age: 41, role: 'Viewer' },
  { name: 'Dave',  age: 25, role: 'Editor' },
];
const sortCol = signal('name');
const sortAsc = signal(true);
const rows = computed(() => {
  const col = sortCol.get();
  const asc = sortAsc.get();
  return [...people]
    .sort((a, b) => {
      const cmp = String(a[col]).localeCompare(String(b[col]));
      return asc ? cmp : -cmp;
    })
    .map(p => t.tr([t.td(p.name), t.td(String(p.age)), t.td(p.role)]));
});
function sortHeader(col, label) {
  const heading = computed(() =>
    sortCol.get() === col
      ? `${label} ${sortAsc.get() ? '↑' : '↓'}`
      : label
  );
  return t.th({
    style: { cursor: 'pointer' },
    onclick: () => {
      if (sortCol.get() === col) {
        sortAsc.set(v => !v);
      } else {
        sortCol.set(col);
        sortAsc.set(true);
      }
    },
  }, heading);
}
document.body.append(
  t.table([
    t.thead(t.tr([
      sortHeader('name', 'Name'),
      sortHeader('age', 'Age'),
      sortHeader('role', 'Role'),
    ])),
    t.tbody(rows),
  ]).toElement()
);

Static HTML tab switcher

When the page is mostly static HTML, a signal and a few effect() calls are enough to add interactivity without rebuilding the markup with Kensington. Here a signal holds the active tab key, and each tab button and content panel reads the signal in its own effect to update its class. The initial active tab is read from the HTML itself so the page works before JavaScript runs.

HTML
<nav class="tabs">
  <button class="tab tab--active" data-tab="overview">Overview</button>
  <button class="tab" data-tab="install">Install</button>
  <button class="tab" data-tab="api">API</button>
</nav>
<div class="panel" data-panel="overview">Overview content...</div>
<div class="panel panel--hidden" data-panel="install">Install content...</div>
<div class="panel panel--hidden" data-panel="api">API content...</div>
JavaScript
import { signal, effect } from 'kensington';
// Read the initial active tab from the DOM so the page is valid before JS runs.
const activeTab = signal(
  document.querySelector('.tab--active')?.dataset.tab ?? 'overview'
);
document.querySelectorAll('[data-tab]').forEach(btn => {
  btn.addEventListener('click', () => activeTab.set(btn.dataset.tab));
  effect(() => {
    btn.classList.toggle('tab--active', btn.dataset.tab === activeTab.get());
  });
});
document.querySelectorAll('[data-panel]').forEach(panel => {
  effect(() => {
    panel.classList.toggle('panel--hidden', panel.dataset.panel !== activeTab.get());
  });
});

Static HTML accordion

Each accordion item gets its own signal , created from its initial aria-expanded attribute. An effect keeps the attribute and the hidden property on the panel in sync as the signal changes. The pattern scales to any number of items with no shared state.

HTML
<div class="accordion">
  <button class="accordion-toggle"
    aria-expanded="false"
    aria-controls="panel-1">What is Kensington?</button>
  <div id="panel-1" class="accordion-panel" hidden>
    An HTML library for Node and the browser.
  </div>
</div>
<div class="accordion">
  <button class="accordion-toggle"
    aria-expanded="true"
    aria-controls="panel-2">Does it require a build step?</button>
  <div id="panel-2" class="accordion-panel">
    No. Import it directly from npm or a CDN.
  </div>
</div>
JavaScript
import { signal, effect } from 'kensington';
document.querySelectorAll('.accordion-toggle').forEach(btn => {
  const panel = document.getElementById(btn.getAttribute('aria-controls'));
  const open = signal(btn.getAttribute('aria-expanded') === 'true');
  btn.addEventListener('click', () => open.set(v => !v));
  effect(() => {
    const isOpen = open.get();
    btn.setAttribute('aria-expanded', String(isOpen));
    panel.hidden = !isOpen;
  });
});

Hydrated component

A like button rendered on the server with real data, then picked up on the client as a live reactive component. The component function is identical in both environments: renderForHydration embeds the initial state and registerComponents mounts it reactively. The click handler applies an optimistic update and reverts if the request fails.

// components/like-button.js
import { t, signal } from 'kensington';
export function likeButton({ postId, likeCount, userLiked }) {
  const likes = signal(likeCount);
  const liked = signal(userLiked);
  function toggle() {
    const next = !liked.get();
    liked.set(next);
    likes.set(n => n + (next ? 1 : -1));
    fetch(`/api/posts/${postId}/like`, { method: next ? 'POST' : 'DELETE' })
      .catch(() => {
        liked.set(!next);
        likes.set(n => n + (next ? -1 : 1));
      });
  }
  return t.button({
    type: 'button',
    class: liked.transform(v => v ? 'like-btn like-btn--active' : 'like-btn'),
    ariaPressed: liked.transform(String),
    onclick: toggle,
  }, [t.span({ ariaHidden: 'true' }, '♥'), ' ', likes]);
}
server.js
import { renderForHydration, t } from 'kensington';
import { likeButton } from './components/like-button.js';
app.get('/posts/:id', async (req, res) => {
  const post = await db.getPost(req.params.id);
  const userLiked = await db.hasLiked(req.user?.id, post.id);
  res.send(
    t.htmlWithDocType({ lang: 'en' }, [
      t.head([
        t.meta({ charset: 'utf-8' }),
        t.title(post.title),
        t.script({ src: '/client.js', type: 'module' }),
      ]),
      t.body(
        t.article([
          t.h1(post.title),
          renderForHydration(likeButton, {
            postId: post.id,
            likeCount: post.likeCount,
            userLiked,
          }),
        ])
      ),
    ]).toString()
  );
});
client.js
import { registerComponents } from 'kensington';
import { likeButton } from './components/like-button.js';
registerComponents({ likeButton });

Form with server-side validation

The form is rendered on the server with renderForHydration and mounted as a reactive component on the client. Submitting calls fetch with the form data as JSON. On validation failure the server returns { errors } and the errors signal updates, reactively showing each message and adding an error class to the affected field. Input values are preserved because the form element stays in place. On success the server returns { success: true } and the client navigates away.

// components/registration-form.js
import { t, signal } from 'kensington';
export function registrationForm() {
  const errors = signal({});
  async function submit(e) {
    e.preventDefault();
    const body = Object.fromEntries(new FormData(e.target));
    const res = await fetch('/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data = await res.json();
    if (data.errors) {
      errors.set(data.errors);
    } else {
      window.location = '/register/success';
    }
  }
  return t.form({ class: 'form', onsubmit: submit }, [
    formField('name',     'Full name', 'text',     errors),
    formField('email',    'Email',     'email',    errors),
    formField('password', 'Password',  'password', errors),
    t.button({ type: 'submit' }, 'Create account'),
  ]);
}
function formField(name, label, type, errors) {
  const error = errors.transform(e => e[name]);
  return t.div({
    class: error.transform(e => e ? 'field field--error' : 'field'),
  }, [
    t.label({ for: name }, label),
    t.input({ id: name, name, type }),
    error.transform(e => e ? t.p({ class: 'field-error' }, e) : null),
  ]);
}
server.js
import { renderForHydration, t } from 'kensington';
import { registrationForm } from './components/registration-form.js';
app.use(express.json());
app.get('/register', (req, res) => {
  res.send(layout('Register', renderForHydration(registrationForm, {})));
});
app.post('/register', async (req, res) => {
  const { name, email, password } = req.body;
  const errors = {};
  if (!name?.trim())
    errors.name = 'Name is required.';
  if (!email?.includes('@'))
    errors.email = 'Enter a valid email address.';
  if ((password?.length ?? 0) < 8)
    errors.password = 'Password must be at least 8 characters.';
  if (Object.keys(errors).length) {
    return res.json({ errors });
  }
  await db.createUser({ name, email, password });
  res.json({ success: true });
});
client.js
import { registerComponents } from 'kensington';
import { registrationForm } from './components/registration-form.js';
registerComponents({ registrationForm });

Lifecycle widget

A polling component that uses addConnectedCallback to start a data fetch loop when mounted, and addDisconnectedCallback to stop it when removed. persist: true keeps the element's signal effects paused rather than destroyed on DOM removal, so the element can be re-inserted and resume reactivity. The connected and disconnected callbacks re-fire on each cycle as part of that mechanism.

import { t, signal } from 'kensington';
function PriceTicker({ symbol }) {
  const price = signal('--');
  const direction = signal(0);
  let prevPrice = null;
  let pollId = null;
  const ticker = t.div(
    { class: 'ticker', persist: true },
    [
      t.span({ class: 'symbol' }, symbol),
      t.span({ class: 'price' }, price),
      t.span(
        { class: direction.transform(d => d > 0 ? 'up' : d < 0 ? 'down' : 'flat') },
        direction.transform(d => d > 0 ? '▲' : d < 0 ? '▼' : '–'),
      ),
    ],
  );
  ticker.addConnectedCallback(function() {
    async function poll() {
      const res = await fetch(`/api/price/${symbol}`);
      const { price: p } = await res.json();
      if (prevPrice !== null) { direction.set(Math.sign(p - prevPrice)); }
      price.set(p.toFixed(2));
      prevPrice = p;
    }
    poll();
    pollId = setInterval(poll, 5000);
  });
  ticker.addDisconnectedCallback(() => {
    clearInterval(pollId);
  });
  return ticker.toElement();
}

Effect pause and resume

effect() returns an object with stop() and resume() . stop() unsubscribes the effect from all signals so it stops reacting to changes. resume() re-runs the callback and re-establishes subscriptions. Together they let you pause and restart a single effect object without creating a new one on every cycle.

The natural home for this is a hand-written web component. The render effect is created once in the constructor and started stopped. connectedCallback resumes it; disconnectedCallback stops it again so signal updates do not fire against a detached element.

import { signal, effect } from 'kensington';
class LiveClock extends HTMLElement {
  #time = signal('');
  #tickId = null;
  #render;
  constructor() {
    super();
    this.#render = effect(() => {
      this.textContent = this.#time.get();
    });
    this.#render.pause(); // start paused; do not render until connected
  }
  connectedCallback() {
    this.#render.resume();
    const tick = () => this.#time.set(new Date().toLocaleTimeString());
    tick();
    this.#tickId = setInterval(tick, 1000);
  }
  disconnectedCallback() {
    this.#render.pause();
    clearInterval(this.#tickId);
  }
}
customElements.define('live-clock', LiveClock);

The effect is defined once, created once, and reused across every connection cycle. Without resume() you would call effect(...) again inside connectedCallback on every reconnection, discarding the previous effect object each time.

Single-page app router

A minimal client-side router built on history.pushState and the popstate event. The current route is held in a signal so any effect or computed that reads it re-runs automatically when the URL changes.

import { t, signal, effect } from 'kensington';
function parseRoute() {
  const [path, search] = window.location.pathname.split('?');
  const params = Object.fromEntries(new URLSearchParams(search));
  const segments = path.split('/').filter(Boolean);
  return { path, segments, params };
}
const route = signal(parseRoute());
function navigate(path) {
  history.pushState(null, '', path);
  route.set(parseRoute());
}
window.addEventListener('popstate', () => route.set(parseRoute()));
// Intercept same-origin <a> clicks so internal links do not cause full reloads.
document.addEventListener('click', e => {
  const a = e.target.closest('a[href]');
  if (!a || a.origin !== location.origin || a.hasAttribute('download')) return;
  e.preventDefault();
  navigate(a.pathname + a.search);
});
const app = document.getElementById('app');
effect(() => {
  const { path } = route.get();
  let view;
  if (path === '/') {
    view = homePage();
  } else if (path.startsWith('/user/')) {
    const id = path.split('/')[2];
    view = userPage(id);
  } else {
    view = notFound();
  }
  app.replaceChildren(view.toElement());
});
function homePage() {
  return t.main([
    t.h1('Home'),
    t.nav([
      t.a({ href: '/user/1' }, 'User 1'),
      ' ',
      t.a({ href: '/user/2' }, 'User 2'),
    ]),
  ]);
}
function userPage(id) {
  return t.main([
    t.h1(`User ${id}`),
    t.a({ href: '/' }, 'Back'),
  ]);
}
function notFound() {
  return t.main(t.h1('404 - Not found'));
}

The click interceptor is the part most often omitted. Without it, internal links trigger a full page reload even with pushState in place. The a.origin !== location.origin check lets external links and target="_blank" links through unmodified.

"Missing" features

These patterns from React have no direct equivalent in Kensington, but can be built in a few lines on top of signal and effect .

createContext

React's createContext / useContext pattern can be built on top of a signal stack. Components call context.get() during synchronous construction to get the nearest provider's signal. provide(value, fn) wraps the value in a new signal, pushes it onto the stack, calls fn() to build the subtree, then pops. Consumers hold the signal reference after construction and update reactively through the normal signal subscription mechanism.

// create-context.js
import { signal } from 'kensington';
function createContext(defaultValue) {
  // each nested .provide call pushes a new value onto the stack at the beginning of the content block
  // and pops it off at the end of the content block
  const _stack = [signal(defaultValue)];
  return {
    get() {
      return _stack.at(-1);
    },
    provide(value, fn) {
      const ctx = signal(value);
      _stack.push(ctx);
      try {
        return fn(ctx);
      } finally {
        _stack.pop();
      }
    },
    set(val) {
      return this.get().set(val);
    },
  };
}
import { t } from 'kensington';
import { createContext } from './create-context.js';
const ThemeContext = createContext('light');
const UserContext = createContext({ name: 'Guest', role: 'viewer' });
function themeCard(title) {
  const theme = ThemeContext.get(); // signal reference captured at construction time; stays reactive
  return t.div({ class: theme.transform(v => `card card--${v}`) }, [
    t.strong(title),
    t.small(['theme: ', theme]),
  ]);
}
function userBadge() {
  const user = UserContext.get();
  return t.span(user.transform(u => `${u.name} (${u.role})`));
}
const app = t.div([
  t.button({
    type: 'button',
    onclick: () => ThemeContext.set(v => v === 'light' ? 'dark' : 'light'),
  }, 'Toggle theme'),
  t.button({
    type: 'button',
    onclick: () => UserContext.set(
      u => u.name === 'Guest'
        ? { name: 'Alice', role: 'admin' }
        : { name: 'Guest', role: 'viewer' }
    ),
  }, 'Toggle login'),
  // No provider. Reads from the default signals.
  t.section([userBadge(), themeCard('Default')]),
  // Static provide. Always dark regardless of the toggle.
  ThemeContext.provide('dark', () =>
    t.section([userBadge(), themeCard('Always dark')]),
  ),
  // User overridden. The login toggle does not affect this subtree.
  UserContext.provide({ name: 'Bob', role: 'editor' }, () =>
    t.section([userBadge(), themeCard('Bob is always the user here')]),
  ),
]);
document.body.append(app.toElement());

useReducer

useReducer centralises state transitions behind a dispatch function. Wrap signal.set with a reducer to get the same pattern: complex state machines stay readable and the call sites only send action objects.

// use-reducer.js
import { signal } from 'kensington';
function useReducer(reducer, initialState) {
  const state = signal(initialState);
  function dispatch(action) {
    state.set(s => reducer(s, action)); // updater form: reducer always sees the latest state
  }
  return { state, dispatch };
}
import { t } from 'kensington';
import { useReducer } from './use-reducer.js';
function cartReducer(state, action) {
  switch (action.type) {
    case 'add':
      return { items: [...state.items, action.item], total: state.total + action.item.price };
    case 'remove': {
      const item = state.items.find(i => i.id === action.id);
      return { items: state.items.filter(i => i.id !== action.id), total: state.total - item.price };
    }
    case 'clear':
      return { items: [], total: 0 };
    default:
      return state;
  }
}
const { state, dispatch } = useReducer(cartReducer, { items: [], total: 0 });
const products = [
  { id: 1, name: 'Widget',    price: 9.99  },
  { id: 2, name: 'Gadget',    price: 24.99 },
  { id: 3, name: 'Doohickey', price: 4.99  },
];
document.body.append(
  t.div([
    t.h2('Shop'),
    t.ul(products.map(p =>
      t.li([p.name, ' — ', t.button({ type: 'button', onclick: () => dispatch({ type: 'add', item: p }) }, 'Add')])
    )),
    t.h2('Cart'),
    t.ul(state.transform(s =>
      s.items.map(item =>
        t.li({ dataKey: item.id }, [
          item.name,
          ' ',
          t.button({ type: 'button', onclick: () => dispatch({ type: 'remove', id: item.id }) }, 'Remove'),
        ])
      )
    )),
    t.p(state.transform(s => `Total: $${s.total.toFixed(2)}`)),
    t.button({ type: 'button', onclick: () => dispatch({ type: 'clear' }) }, 'Clear cart'),
  ]).toElement()
);

useLocalStorage

A signal that reads its initial value from localStorage and writes back on every change. The effect handles the sync; the rest of your code just reads and sets the signal normally. Guard the initial read with isBrowser so server-rendered components do not throw.

// use-local-storage.js
import { signal, effect, isBrowser } from 'kensington';
function useLocalStorage(key, defaultValue) {
  const stored = isBrowser ? localStorage.getItem(key) : null;
  const s = signal(stored !== null ? JSON.parse(stored) : defaultValue); // !== null: stored could be '0', 'false', etc.
  effect(() => {
    localStorage.setItem(key, JSON.stringify(s.get()));
  });
  return s;
}
import { t } from 'kensington';
import { useLocalStorage } from './use-local-storage.js';
const theme = useLocalStorage('theme', 'light');
document.body.append(
  t.div([
    t.p(['Current theme: ', theme]),
    t.button({
      type: 'button',
      onclick: () => theme.set(v => v === 'light' ? 'dark' : 'light'),
    }, theme.transform(v => `Switch to ${v === 'light' ? 'dark' : 'light'} mode`)),
  ]).toElement()
);

useDebounce

Returns a derived signal that only updates after the source has been stable for delay milliseconds. Each time the source changes, the pending timeout is cleared and restarted. Because effect does not support a cleanup return value, the timeout ID lives in the enclosing closure.

// use-debounce.js
import { signal, effect } from 'kensington';
function useDebounce(source, delay) {
  const debounced = signal(source.get());
  let id;
  effect(() => {
    const value = source.get();
    clearTimeout(id);
    id = setTimeout(() => debounced.set(value), delay);
  });
  return debounced;
}
import { signal, effect, t } from 'kensington';
import { useDebounce } from './use-debounce.js';
const query    = signal('');
const debounced = useDebounce(query, 300);
const results  = signal([]);
// fetch fires only after the user pauses, not on every keystroke
effect(() => {
  const q = debounced.get();
  if (!q) { results.set([]); return; }
  fetch(`/api/search?q=${encodeURIComponent(q)}`)
    .then(r => r.json())
    .then(data => results.set(data));
});
document.body.append(
  t.div([
    t.input({
      type: 'search',
      placeholder: 'Search...',
      oninput: e => query.set(e.target.value),
    }),
    t.ul(results.transform(items => items.map(r => t.li(r)))),
  ]).toElement()
);

useFetch

Returns { data, loading, error } signals that update as the request progresses. When the URL signal changes, the in-flight request is aborted via AbortController before the new one starts. The abort controller lives in the closure for the same reason as the debounce timeout -- effect does not support a cleanup return value.

// use-fetch.js
import { signal, effect } from 'kensington';
function useFetch(urlSignal) {
  const data    = signal(null);
  const loading = signal(true);
  const error   = signal(null);
  let controller;
  effect(() => {
    if (controller) controller.abort(); // cancel any in-flight request before starting a new one
    controller = new AbortController();
    loading.set(true);
    error.set(null);
    fetch(urlSignal.get(), { signal: controller.signal })
      .then(r => r.json())
      .then(json => { data.set(json); loading.set(false); })
      .catch(err => {
        if (err.name !== 'AbortError') { error.set(err.message); loading.set(false); } // AbortError is expected when we cancel; not a real failure
      });
  });
  return { data, loading, error };
}
import { signal, t } from 'kensington';
import { useFetch } from './use-fetch.js';
const userId = signal(1);
// derived signal: re-fetches automatically whenever userId changes
const { data, loading, error } = useFetch(userId.transform(id => `/api/users/${id}`));
document.body.append(
  t.div([
    t.div([
      t.button({ type: 'button', onclick: () => userId.set(v => v - 1) }, 'Prev'),
      t.span([' User ', userId, ' ']),
      t.button({ type: 'button', onclick: () => userId.set(v => v + 1) }, 'Next'),
    ]),
    // signal content can be a tag — switches between loading, error, and data views reactively
    loading.transform(l => l
      ? t.p('Loading...')
      : error.get()
        ? t.p({ class: 'error' }, error.get())
        : t.pre(JSON.stringify(data.get(), null, 2))
    ),
  ]).toElement()
);

useId

Generates a unique, stable ID for pairing form labels with inputs. A module-level counter increments once per call. On the server it produces the same sequence on every request, so IDs in SSR output and client hydration match as long as components are called in the same order.

// use-id.js
let _id = 0;
function useId(prefix = 'k') {
  return `${prefix}-${++_id}`;
}
import { t } from 'kensington';
import { useId } from './use-id.js';
function labeledInput(label, type = 'text') {
  const id = useId();
  return t.div({ class: 'field' }, [
    t.label({ for: id }, label),
    t.input({ id, type }),
  ]);
}
document.body.append(
  t.form([
    labeledInput('Full name'),
    labeledInput('Email', 'email'),
    labeledInput('Password', 'password'),
    t.button({ type: 'submit' }, 'Sign up'),
  ]).toElement()
);

Integrations

htmx

Pass 'hx' to additionalNamespaces to allow hx-* attributes. Alpine.js uses 'x' .

import Kensington from 'kensington';
const t = new Kensington({ additionalNamespaces: ['hx'] });
// Live search: htmx swaps in the result fragment
t.div([
  t.input({
    type: 'search',
    name: 'q',
    placeholder: 'Search...',
    hxGet: '/search',
    hxTrigger: 'input changed delay:300ms',
    hxTarget: '#results',
  }),
  t.ul({ id: 'results' }),
]);
// The partial route returns just the <li> items (htmx swaps them into the <ul>)
app.get('/search', async (req, res) => {
  const rows = await db.search(req.query.q);
  res.send(rows.map(r => t.li(r.name)).join('\n'));
});

Tailwind CSS

The class array is a natural fit for Tailwind. Falsy entries are dropped, so conditional classes don't need ternaries or string concatenation.

import { t } from 'kensington';
function button(label, { variant = 'primary', disabled = false } = {}) {
  return t.button({
    type: 'button',
    disabled,
    class: [
      'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium',
      'focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors',
      variant === 'primary'   && 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
      variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
      variant === 'danger'    && 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
      disabled && 'opacity-50 cursor-not-allowed',
    ],
  }, label);
}
function card(title, body) {
  return t.div({ class: 'rounded-lg border border-gray-200 bg-white shadow-sm p-6' }, [
    t.h3({ class: 'text-lg font-semibold text-gray-900 mb-2' }, title),
    t.div({ class: 'text-gray-600 text-sm' }, body),
  ]);
}
function alert(message, type = 'info') {
  const styles = {
    info:    'bg-blue-50 text-blue-800 border-blue-200',
    success: 'bg-green-50 text-green-800 border-green-200',
    warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
    error:   'bg-red-50 text-red-800 border-red-200',
  };
  return t.div({ class: `rounded-md border px-4 py-3 text-sm ${styles[type]}` }, message);
}
t.div({ class: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8' }, [
  t.h1({ class: 'text-3xl font-bold text-gray-900 mb-6' }, 'Dashboard'),
  alert('Your trial expires in 3 days.', 'warning'),
  t.div({ class: 'mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3' }, [
    card('Users', '1,284 total'),
    card('Revenue', '$24,500 this month'),
    card('Active sessions', '42 right now'),
  ]),
  t.div({ class: 'mt-8 flex gap-3' }, [
    button('Save changes'),
    button('Cancel', { variant: 'secondary' }),
    button('Delete account', { variant: 'danger' }),
  ]),
]);

Alpine.js

Pass 'x' to additionalNamespaces to allow x-* attributes. The camelCase conversion handles xOn , xBind , xShow , etc. automatically.

import Kensington from 'kensington';
const t = new Kensington({ additionalNamespaces: ['x'] });
// Dropdown menu
function dropdown(label, items) {
  return t.div({ xData: '{ open: false }', class: 'dropdown' }, [
    t.button({
      type: 'button',
      xOn: { click: 'open = !open' },
      xBind: { ariaExpanded: 'open' },
    }, label),
    t.ul({
      xShow: 'open',
      xOn: { 'click.outside': 'open = false' },
      class: 'dropdown-menu',
    }, items.map(item =>
      t.li(t.a({ href: item.href }, item.label))
    )),
  ]);
}
// Tabs
function tabs(items) {
  return t.div({ xData: '{ active: 0 }', class: 'tabs' }, [
    t.div({ class: 'tab-list', role: 'tablist' },
      items.map((item, i) =>
        t.button({
          type: 'button',
          role: 'tab',
          xOn: { click: `active = ${i}` },
          xBind: { class: `active === ${i} ? 'tab--active' : ''` },
        }, item.label)
      )
    ),
    t.div({ class: 'tab-panels' },
      items.map((item, i) =>
        t.div({ role: 'tabpanel', xShow: `active === ${i}` }, item.content)
      )
    ),
  ]);
}

Elysia

Elysia runs on Bun. Pass the tag's string representation to new Response() and set the content-type header manually, since Elysia doesn't have a dedicated HTML response method.

import { Elysia } from 'elysia';
import { t } from 'kensington';
import { layout } from './layout.js';
const app = new Elysia()
  .get('/', () => new Response(
    layout('Home', t.h1('Welcome')),
    { headers: { 'content-type': 'text/html; charset=utf-8' } }
  ))
  .get('/users', async () => {
    const users = await db.getUsers();
    return new Response(
      layout('Users', [
        t.h1('Users'),
        t.ul(users.map(u => t.li(u.name))),
      ]),
      { headers: { 'content-type': 'text/html; charset=utf-8' } }
    );
  })
  .listen(3000);

Hono

Hono runs on Node, Bun, Deno, and Cloudflare Workers. Use c.html() to send a Kensington string as an HTML response.

import { Hono } from 'hono';
import { t } from 'kensington';
import { layout } from './layout.js';
const app = new Hono();
app.get('/', c => c.html(
  layout('Home', t.h1('Welcome'))
));
app.get('/users/:id', async c => {
  const user = await db.getUser(c.req.param('id'));
  return c.html(
    layout(user.name, [
      t.h1(user.name),
      t.p(user.bio),
    ])
  );
});
export default app;

For Cloudflare Workers, export app as the default and set compatibility_date in wrangler.toml . The same Kensington code runs unchanged across every Hono runtime.

Web Components

Kensington and signals map naturally onto the custom element lifecycle. Build the element tree with toElement() in connectedCallback and let the signal effects keep it up to date. Use persist: true on toElement() so effects pause on removal and resume on re-insertion rather than being destroyed.

import { t, signal, computed } from 'kensington';
class UserCard extends HTMLElement {
  #name = signal('');
  #role = signal('');
  #initials = computed(() => {
    return this.#name.get()
      .split(' ')
      .map(w => w[0])
      .join('')
      .toUpperCase();
  });
  static get observedAttributes() { return ['name', 'role']; }
  attributeChangedCallback(attr, _prev, next) {
    if (attr === 'name') this.#name.set(next ?? '');
    if (attr === 'role') this.#role.set(next ?? '');
  }
  connectedCallback() {
    this.replaceChildren(
      t.div({ class: 'user-card' }, [
        t.div({ class: 'user-card__avatar' }, this.#initials),
        t.div({ class: 'user-card__body' }, [
          t.strong(this.#name),
          t.span({ class: 'user-card__role' }, this.#role),
        ]),
      ]).toElement()
    );
  }
}
customElements.define('user-card', UserCard);

Passing a signal directly to a tag ( t.strong(this.#name) ) sets up a live text effect inside toElement() . Updating the attribute calls attributeChangedCallback , which sets the signal, which updates only the affected text node. The dom-tracker cleans up the effects automatically when the element is removed from the DOM.

D3

Use Kensington to build the SVG container, then hand it to D3 for data-driven rendering. Wrap the D3 draw logic in an effect so the chart redraws automatically whenever the signal holding the data changes.

import { t, signal, effect } from 'kensington';
import * as d3 from 'd3';
const data = signal([12, 40, 28, 55, 33, 20, 47]);
const W = 500, H = 220;
const m = { top: 10, right: 10, bottom: 30, left: 34 };
const svg = t.svg({ width: W, height: H, viewBox: `0 0 ${W} ${H}` }).toElement();
document.getElementById('chart').replaceChildren(svg);
effect(() => {
  const values = data.get();
  const x = d3.scaleBand()
    .domain(values.map((_, i) => i))
    .range([m.left, W - m.right])
    .padding(0.2);
  const y = d3.scaleLinear()
    .domain([0, d3.max(values)])
    .nice()
    .range([H - m.bottom, m.top]);
  const chart = d3.select(svg);
  chart.selectAll('*').remove();
  chart.append('g')
    .attr('transform', `translate(0,${H - m.bottom})`)
    .call(d3.axisBottom(x).tickFormat(i => `Day ${i + 1}`));
  chart.append('g')
    .attr('transform', `translate(${m.left},0)`)
    .call(d3.axisLeft(y).ticks(5));
  chart.selectAll('rect')
    .data(values)
    .join('rect')
    .attr('x',      (_, i) => x(i))
    .attr('y',      d => y(d))
    .attr('width',  x.bandwidth())
    .attr('height', d => y(0) - y(d))
    .attr('fill',   'steelblue');
});
// Replace the data to redraw the chart.
document.getElementById('refresh').addEventListener('click', () => {
  data.set(Array.from({ length: 7 }, () => Math.round(Math.random() * 60) + 5));
});

D3 owns the contents of the SVG element. Kensington owns everything outside it. The surrounding layout, controls, and any other reactive UI on the page belong to Kensington. The two libraries operate in separate parts of the DOM and do not conflict.

Build systems

The recommended setup runs the full Kensington build in development (with runtime validation on) and the slim build in production (with no validation). The Vite example on the home page shows the pattern. The same idea works in every bundler that supports module aliasing.

Rollup

Use @rollup/plugin-alias to swap the import in production. @rollup/plugin-replace sets process.env.NODE_ENV so application code can pick a validationLevel at build time.

// rollup.config.js
import alias from '@rollup/plugin-alias';
import nodeResolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
const production = process.env.NODE_ENV === 'production';
export default {
  input: 'src/main.js',
  output: { file: 'dist/bundle.js', format: 'es' },
  plugins: [
    nodeResolve(),
    replace({
      preventAssignment: true,
      'process.env.NODE_ENV': JSON.stringify(production ? 'production' : 'development'),
    }),
    production && alias({
      entries: [{ find: 'kensington', replacement: 'kensington/dist/slim' }],
    }),
  ].filter(Boolean),
};
// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: process.env.NODE_ENV === 'production' ? 'off' : 'error',
});

Run with NODE_ENV=production rollup -c for the slim bundle, rollup -c for the full one.

esbuild

esbuild has built-in support for both aliasing and environment-variable replacement via alias and define . No plugins required.

// build.js
import esbuild from 'esbuild';
const production = process.env.NODE_ENV === 'production';
await esbuild.build({
  entryPoints: ['src/main.js'],
  outfile: 'dist/bundle.js',
  bundle: true,
  format: 'esm',
  define: {
    'process.env.NODE_ENV': JSON.stringify(production ? 'production' : 'development'),
  },
  alias: production
    ? { kensington: 'kensington/dist/slim' }
    : {},
});
// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: process.env.NODE_ENV === 'production' ? 'off' : 'error',
});

Run node build.js for the dev build, NODE_ENV=production node build.js for the slim one.

Webpack

Webpack's mode option auto-sets process.env.NODE_ENV , and resolve.alias handles the import swap. A config function receives the mode so the alias map can be built per environment.

// webpack.config.js
const path = require('path');
module.exports = (env, argv) => ({
  entry: './src/main.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
  resolve: {
    alias: argv.mode === 'production'
      ? { kensington: 'kensington/dist/slim' }
      : {},
  },
});
// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: process.env.NODE_ENV === 'production' ? 'off' : 'error',
});

Run webpack --mode development for the full build, webpack --mode production for the slim one.

GitHub

Kensington API

Method signatures, types, and exports. See the guide for usage examples.

Constructor

new Kensington(options?: {
  validationLevel?: 'off' | 'warn' | 'error';
  additionalNamespaces?: string | string[];
  additionalGlobalAttributes?: Record<string, unknown>;
  indentationLevel?: number;
  logger?: (message: string) => void;
})
Option Default Description
validationLevel 'off' Attribute validation behavior. 'off' disables validation entirely (required for the slim build). 'warn' logs via logger . 'error' throws.
additionalNamespaces Allow extra attribute prefixes on all elements, e.g. 'hx' for htmx hx-* attributes or 'x' for Alpine.js.
additionalGlobalAttributes Allow specific extra attributes on all elements. Same validator format as createCustomTag .
indentationLevel 2 Spaces per indentation level in .toString() output. Set to 0 to disable indentation.
logger console.log Called with warning messages when validationLevel is 'warn' .

Tag methods

Every HTML, SVG, and MathML element is available as a method on the Kensington instance. Attribute types are generated from the official specs; each element's attribute type is named {PascalTag}Attributes (e.g. InputAttributes , AAttributes ).

Content elements

Most elements: div , p , span , section , a , table , and so on.

t.div(attributes: DivAttributes, content?: Content): DivTag
t.div(content?: Content): DivTag

A subset of elements have branded return types and enforce a strict content model in TypeScript. Passing the wrong child type is a compile-time error. All strict containers also accept literal() , inlineComment() , null , undefined , and boolean as escape hatches for conditional patterns.

Element(s) Return type Accepts
html HtmlTag head , body
table TableTag caption , colgroup , thead , tbody , tfoot , tr
thead , tbody , tfoot TheadTag , TbodyTag , TfootTag tr
tr TrTag td , th
colgroup ColgroupTag col , template
ul , ol , menu UlTag , OlTag , MenuTag li
dl DlTag dt , dd , div
select SelectTag option , optgroup , hr , div , button
optgroup OptgroupTag option , div , noscript , legend
picture PictureTag source , img
hgroup HgroupTag h1h6 , p

These elements also have branded return types but accept any Content : td , th , li , dt , dd , caption , option , body , head , and others. Void elements with branded types: img ( ImgTag ), col ( ColTag ), source ( SourceTag ), hr ( HrTag ).

Void elements

Void elements take no content argument: br , hr , input , img , link , meta , source , wbr , and others.

t.input(attributes?: InputAttributes): VoidTag
t.img(attributes?: ImgAttributes): ImgTag

Instance methods

All tag objects share these methods.

Method Returns Description
.toString() string Serializes to an indented HTML string. Text content is HTML-encoded. Signal values are read as a snapshot at call time.
.toElement(options?) Element Creates a live DOM node. Signal attribute values, signal content, and prop values update the DOM in place when the signal changes. Signal effects are stopped automatically when the element is removed from the DOM. Pass { persist: true } to pause effects on removal and resume them automatically on re-insertion instead of stopping permanently. Browser only. LiteralTag returns a DocumentFragment ; CommentTag returns a Comment .
.getDomElement() Element | null Returns the live DOM element created by a prior .toElement() call if it is still connected to the document, otherwise null .
.addConnectedCallback(fn) this Registers a callback that fires when the element is inserted into the DOM. fn receives the live element as its first argument and as this . Call before .toElement() . With toElement({ persist: true }) the callback re-fires on every re-insertion. Can be called multiple times to register multiple handlers.
.addDisconnectedCallback(fn) this Registers a callback that fires when the element leaves the DOM, after signal effects are stopped. With toElement({ persist: true }) the callback re-fires on every removal and is re-registered automatically on each reconnection, so the full enter/leave cycle repeats without extra setup.

Special methods

htmlWithDocType

Identical to t.html() but prepends <!DOCTYPE html> to the output.

t.htmlWithDocType(attributes: HtmlAttributes, content?: HtmlContent): ContentTag
t.htmlWithDocType(content?: HtmlContent): ContentTag

literal / unsafeLiteral

t.literal(str: string): LiteralTag
t.unsafeLiteral(str: string): LiteralTag

literal embeds a raw HTML string into the output. <script> tags trigger a validation warning or error. unsafeLiteral skips that check and should only be used for trusted HTML.

inlineComment

t.inlineComment(str: string | number): CommentTag

Single-line strings produce <!-- text --> . Multi-line strings are formatted across multiple lines.

createCustomTag

t.createCustomTag(
  tagName: string,
  allowedAttributes?: Record<string, AttributeValidator>
): ContentMethod<T>

Returns a method for a custom element. Assign to a class property and annotate with ContentMethod<T> for typed attributes.

Each value in allowedAttributes is a validator:

Validator Accepts
String Any string value
Number Any number value
Boolean true or false
['a', 'b', ...] One of the listed string literals
v => boolean Custom predicate function
class MyEngine extends Kensington {
  myCard = this.createCustomTag('my-card', {
    'card-type': ['primary', 'secondary'],
    'loading': Boolean,
    'max-items': Number,
    'score': v => typeof v === 'number' && v <= 100,
  });
}

To extend a built-in element, spread its attribute object from kensington/attributes :

import { buttonAttributes } from 'kensington/attributes';
class MyEngine extends Kensington {
  button = this.createCustomTag('button', {
    ...buttonAttributes,
    popovertarget: String,
  });
}

Signals

Signals are reactive values. Read them with .get() , write them with .set() , and derive new ones with computed() or .transform() . Pass a signal as an option value or content and .toElement() wires up live DOM updates automatically.

signal

import { signal } from 'kensington';
signal<T>(initialValue: T): Signal<T>

Creates a writable signal holding initialValue .

Signal methods

Method Description
.get(): T Returns the current value. When called inside computed() or effect() , registers this signal as a dependency of the running computation.
.value: T Property getter. Returns the current value without tracking. Unlike .get() , reading .value inside computed() or effect() does not subscribe to this signal. The computation will not re-run when this signal changes.
.set(value: T | (prev: T) => T): void Updates the value and notifies subscribers. Accepts a new value or an updater function. Throws if called on a signal created by computed() or .transform() .
.transform<U>(fn: (value: T) => U): Signal<U> Returns a new read-only derived signal equivalent to computed(() => fn(this.get())) . Tracks all signals read inside fn , not just the source.
.stop(): void Clears all subscribers. For signals created by computed() or .transform() , also tears down the derived computation and freezes the value.
.toJSON(): T Returns the raw value without tracking side effects. Makes signals transparent to JSON.stringify .
.toString(): string Calls .get() and converts to string. Allows signals to be used in template literals inside reactive contexts.

computed

import { computed } from 'kensington';
computed<T>(fn: () => T): Signal<T>

Creates a read-only signal whose value is derived from other signals. Re-evaluates fn synchronously whenever any signal read inside it changes. The returned signal exposes .stop() to unsubscribe from all tracked signals and freeze the value.

const count = signal(0);
const label = computed(() => count.get() === 1 ? 'item' : 'items');
label.stop(); // unsubscribes from tracked signals, value freezes

effect

import { effect } from 'kensington';
effect(fn: () => void): { pause(): void, resume(): void, stop(): void }

Runs fn immediately and re-runs it whenever any signal read inside it changes. Re-runs are deferred via queueMicrotask , so multiple synchronous .set() calls in the same turn batch into one re-run. Errors thrown inside the callback are re-surfaced asynchronously so they do not abort other pending effects.

const e = effect(() => {
  document.title = `${count.get()} items`;
});
e.pause();  // unsubscribes temporarily
e.resume(); // re-runs fn and re-establishes subscriptions
e.stop();   // permanently destroys. resume() after stop() is a no-op

Elements created with .toElement() automatically stop their signal effects when removed from the DOM. During SSR ( renderForHydration ), effect() is a no-op.

prop key

Use the prop key to assign DOM properties directly ( el[name] = value ) instead of using setAttribute . This matters for properties that diverge from their HTML attributes after user interaction — notably value and checked on form elements — and for properties with no attribute equivalent such as muted and playbackRate on media elements.

const query = signal('');
// Assigns el.value reactively — keeps the live property in sync
t.input({ type: 'search', prop: { value: query } }).toElement();
// Static prop — assigned once at render time
t.video({ src: '/intro.mp4', prop: { muted: true, playbackRate: 1.5 } }).toElement();

Accepts a plain object whose values are static or ReadonlySignal . Silently ignored in .toString() . Known writable properties (those on the element's DOM interface) are typed in TypeScript. Expando properties and arbitrary string keys are also accepted. Property existence and writability are validated at render time against the live element and reported via validationLevel .

t.input({ prop: { value: 'hello' } });           // typed: HTMLInputElement.value
t.input({ prop: { checked: isChecked } });       // typed: boolean
t.video({ prop: { muted: true, playbackRate: 1.5 } });  // typed: HTMLVideoElement props
t.div({ prop: { _instance: component } });       // expando: accepted as unknown

renderForHydration

import { renderForHydration } from 'kensington';
renderForHydration(
  fn: (state: Record<string, unknown>) => ContentTag | ContentTag[] | null | undefined,
  state: Record<string, unknown>,
  name?: string
): LiteralTag

Renders a component to an HTML string for server-side delivery, then embeds the state as a <script type="application/json"> block so the browser can replace it with a live reactive DOM. Signal effects are suppressed during the component call. The component function must be synchronous.

name defaults to fn.name when called server-side. Pass an explicit string when calling in the browser. Bundlers and minifiers rename function identifiers, so fn.name is not reliable after a production build. Passing an explicit name is also required for anonymous functions. The same name is used by registerComponents to match script blocks to component functions on the client.

State must be a plain serializable object. Values that cannot survive JSON.stringify (functions, symbols, BigInt, circular references, class instances) cause a warning or throw.

// server
res.send(layout(renderForHydration(counter, { count: 0 })).toString());

registerComponents

import { registerComponents } from 'kensington';
registerComponents(
  components: Record<string, Function>
): { stop(): void }

Registers component functions and hydrates all server-rendered instances already in the page. Each matching <script type="application/json" data-k-component="…"> block is replaced with the live reactive DOM produced by the component function. A MutationObserver is installed to handle components inserted dynamically after this call.

Returns { stop() } to disconnect the observer and halt auto-hydration.

// client
const { stop } = registerComponents({ counter, userCard });
// later, if you want to stop watching for new components:
stop();

Exports

kensington

import Kensington from 'kensington';                         // the class
import { t } from 'kensington';                              // shared default instance (new Kensington())
import { signal, computed, effect } from 'kensington';
import { renderForHydration, registerComponents } from 'kensington';
import { isBrowser } from 'kensington';                      // true when window is defined
// browser, via CDN
import { t } from 'https://cdn.jsdelivr.net/npm/kensington/dist/kensington.min.js';

kensington/attributes

Every element has a named export containing its allowed-attribute validator object. Useful for extending built-in elements via createCustomTag .

import {
  divAttributes,
  inputAttributes,
  formAttributes,
  buttonAttributes,
  aAttributes,
  // ... one export per element
} from 'kensington/attributes';

Slim build

Proxy-based class with no per-element attribute spec data. About 5× smaller minified (~148 KB to ~27 KB). For signal-only consumers tree-shaking drops the bundle to ~1.5 KB. Throws if validationLevel is anything other than 'off' . See Dev vs production for the recommended workflow.

import Kensington from 'kensington/dist/slim';
const t = new Kensington();

TypeScript types

import type {
  ContentTag, VoidTag, LiteralTag, CommentTag,
  Content, ContentMethod,
  Signal, ReadonlySignal, Reactive,
  GlobalAttributes, GlobalEvents, UniversalAttributes, NameSpaceAttributes,
  // branded element types:
  DivTag, TdTag, ThTag, TrTag, TheadTag, TbodyTag, TfootTag,
  TableTag, UlTag, OlTag, LiTag, DlTag, SelectTag, ImgTag,
  // ...
} from 'kensington';
Type Description
ContentTag Base type returned by all content element methods. All branded element types extend this.
VoidTag Returned by void element methods ( br , input , …). Extends ContentTag .
LiteralTag Returned by .literal() and .unsafeLiteral() .
CommentTag Returned by .inlineComment() .
DivTag , TdTag , LiTag , … Branded return types for elements with content model constraints. Extend ContentTag .
Content string | number | boolean | null | undefined | ContentTag | VoidTag | LiteralTag | CommentTag | Content[] . Falsy values are silently dropped.
ContentMethod<T> Type of a custom element method created by createCustomTag . T is the element-specific attribute shape.
Signal<T> Writable signal returned by signal() . Implements ReadonlySignal<T> .
ReadonlySignal<T> Read-only signal interface returned by computed() and .transform() . Exposes .get() , .value , .stop() , and .transform() .
Reactive<T> T | ReadonlySignal<T> . The type of every attribute value. Accepts a plain value or a signal that resolves to that value.
GlobalAttributes Attributes shared by all HTML elements ( id , class , style , …).
GlobalEvents Event handler attributes ( onclick , oninput , …) shared by all elements.
NameSpaceAttributes Interface to extend via module augmentation to allow custom attribute namespaces.
UniversalAttributes Intersection of GlobalAttributes , GlobalEvents , and NameSpaceAttributes .

Module augmentation

Extend NameSpaceAttributes to allow custom attribute prefixes without a custom subclass:

declare module 'kensington' {
  interface NameSpaceAttributes {
    [key: `hx${string}`]: string | object; // htmx hx-* attributes
  }
}

Want to understand how everything works under the hood? See the architecture guide .

GitHub

Architecture

A complete trace of what happens from t.div(...) through DOM teardown. Every signal subscription, every cleanup hook, every step of the pipeline.

Introduction

This document is the deep-dive companion to the source code. It traces what happens during the life of a Kensington tag instance, from the moment t.div(...) is called until the resulting DOM node and its signal subscriptions are torn down.

You don't need to read this to use Kensington. Read it if you're:

Throughout this page, source references appear as signal.js . Click to open the file on GitHub. Line numbers are approximate and may drift as the code evolves.

Concepts at a glance

If you've never read the source, these are the seven moving parts you'll see referenced throughout. Each links to the section that explains it in full.

Concept What it is Lives in
Tag instance The object returned by t.div(...) . Holds attributes, content, namespace, and lifecycle callback arrays. Two output methods: toString() and toElement() . content-tag.js
Signal A reactive value container. .get() subscribes the current effect; .set() schedules every subscriber. .value reads without subscribing. signal.js
effect A closure that re-runs whenever any signal it reads changes. Exposes pause , resume , stop . signal.js
Lifecycle Per-element orchestrator that owns every signal effect, the persist mechanism, and the connect/disconnect callback chain. lifecycle.js
DOM tracker A shared MutationObserver that fires stop chains on removal and connect callbacks on insertion. One per document. dom-tracker.js
Reconciler Patches DOM in place when a signal value is an array. Matches nodes by data-key , diffs recursively, guards for signal-managed elements. reconcile.js
persist mode Opt-in via the persist tag option. Effects pause on removal and resume on reconnect. Disconnect/connect callbacks fire every cycle. lifecycle.js
The mental model in three sentences

Every tag is a plain object that becomes a string or an element on demand. When it becomes an element, a Lifecycle wires every signal-driven value into an effect bound to that element via WeakRef . A document-wide MutationObserver watches for that element's removal and tears the effects down, or pauses them if persist is on.

Construction String output DOM output Lifecycle Removal

The Pipeline

Kensington has two output modes from one tag instance. The same ContentTag object can produce an HTML string via toString or a live DOM tree via toElement . The pipelines diverge only at the rendering stage.

flowchart TD
  A["t.div(attrs, content)"] --> B["createTag closure"]
  B --> C["new ContentTag(options)"]
  C --> D["collectContent: flatten arrays, drop falsy"]
  D --> E{"validationLevel != 'off'?"}
  E -- yes --> F["validate(tag)"]
  E -- no --> G["tag instance returned"]
  F --> G
  G --> H{"User calls..."}
  H -- "toString()" --> I["renderToString"]
  H -- "toElement()" --> J["DOM build + Lifecycle"]
  I --> K["HTML string"]
  J --> L["Live element"]

t is an instance of the generated Kensington class at kensington.js . Every tag method ( t.div , t.span , etc.) is a closure produced by createTag . The closure captures the tag name, the allowed-attribute spec map, the Klass (which subclass to instantiate), and per-tag options like namespace or contentIsLiteral .

Generated file

esm/kensington.js is generated by write-code-files.js from spec data. Do not edit it directly. All hand-written source lives in and .

Stage 1: Tag Construction

The closure returned by createTag accepts several call forms:

t.div();                              // no attributes, no content
t.div('hello');                        // content only
t.div({ class: 'a' });                 // attributes only
t.div({ class: 'a' }, 'hello');        // attributes + content
t.div({ class: 'a' }, [t.p(), t.p()]); // attributes + array content

The closure body disambiguates these forms by inspecting the first argument's prototype. A plain object ( Object.prototype or null prototype) is treated as attributes. Anything else, a tag instance, array, string, number, or Signal, is treated as content.

The createTag closure

At kensington.js , each closure instantiates the appropriate tag class with a consistent options object:

const instance = new Klass({
  additionalGlobalAttributes: this.additionalGlobalAttributes,
  allowedAttributeMap,    // built once when createTag was called
  attributes,
  content,
  contentIsLiteral,
  encodeContent,
  indentationLevel: this.indentationLevel,
  logger: this.logger,
  namespace,
  namespaces: this.namespaces,
  tagName,
  validationLevel: this.validationLevel,
});

The allowedAttributeMap is built once when createTag is first called and shared across every invocation of that closure. Validating t.div(...) a million times does not rebuild the spec map a million times.

The ContentTag constructor

esm / tag-classes / content-tag.js

The constructor stores options on instance fields, flattens content via collectContent, and initializes private callback arrays:

class ContentTag {
  #connectedCallbacks = [];
  #disconnectedCallbacks = [];
  #domElement = null;
  constructor(options) {
    this.tagName = options.tagName;
    this.attributes = options.attributes;
    this.prop = options.attributes?.prop ?? null;
    this.validationLevel = options.validationLevel;
    this.content = collectContent(options.content);
    // ... other fields
  }
}

collectContent

Defined at content-tag.js . Recursively flattens nested arrays into a single linear list and drops items that should not render:

function collectContent(items, seen = new Set()) {
  const out = [];
  for (const c of [].concat(items)) {
    if ([undefined, null, '', false, true].includes(c)) {
      continue; // false/true arise from conditional patterns: condition && t.span(...)
    }
    if (Array.isArray(c)) {
      if (seen.has(c)) { continue; } // cycle detection
      seen.add(c);
      out.push(...collectContent(c, seen));
      continue;
    }
    out.push(c);
  }
  return out;
}
Key behaviors
  • false and true are dropped. This is what makes condition && t.span(...) work.
  • null , undefined , and empty string are dropped.
  • Arrays flatten recursively. A cycle-detection Set prevents infinite recursion on accidentally circular content.
  • Signals pass through unchanged and are resolved at render time.

Validation

If validationLevel is 'warn' or 'error', the tag runs validate() immediately after construction (see validate.js ):

  1. Collect unallowed attributes. Filter keys through attributeIsValid . Allowed if it's on or prop , in allowedAttributeMap , matches a namespace prefix ( data- , aria- , custom), or is in additionalGlobalAttributes .
  2. Report them via showInvalid. At 'warn' this logs; at 'error' this throws.
  3. Collect invalid attribute values. For each allowed attribute, run attributeValueIsValid against the type spec.
  4. Report invalid values as a single combined message so the developer sees all problems at once.
Never throws at 'off'

All validation goes through show-invalid.js . At 'off' it's a no-op. Production deployments run with 'off' for performance. A malformed attribute in user data must not crash the page.

Signal instances are accepted unconditionally for any attribute type. The actual value is only inspected at render time. See validate.js .

Stage 2: String Output

tag.toString() delegates to renderToString at serialize.js :

  1. Filter invalid content via validateContent() . Items that aren't a string, finite number, tag instance, or Signal are dropped and reported via showInvalid.
  2. Open the tag. Concatenate '<' , the tag name, the attribute string, and '>' .
  3. Render the content body via one of three paths (below).
  4. Close the tag. Concatenate '</' , the tag name, '>' .

Three content paths

renderToString picks a path based on tag type and content shape:

Path A
Literal content
For <script> and <style> tags ( contentIsLiteral ). Content is joined by newlines without HTML encoding.
Path B
Short single-line
Fast path when content is a single string or number under 100 characters with no line breaks. Concatenates directly without the stringifyContentArray and indent overhead.
Path C
Multi-line indented
Everything else. Resolves Signals via .get() , flattens, passes to stringifyContentArray, then applies indent at the tag's indentation level.

The selector is contentIsShort(tag) at serialize.js :

export function contentIsShort(tag) {
  if (!tag.content.length) { return true; }
  if (tag.content.length > 1) { return false; }
  let [content] = tag.content;
  if (content instanceof Signal) { content = content.get(); }
  if (!['string', 'number'].includes(typeof content)) { return false; }
  if (content.length > 100) { return false; }
  return !LINE_BREAK_TEST_REGEX.test(content);
}

Attribute serialization

attributeString(tag) calls attributesStringFromObject at attributes.js . It iterates the attribute array and serializes each pair as name="value" with HTML encoding. Booleans render as the bare attribute name ( disabled not disabled="true" ). Function values cannot be serialized to strings and are silently omitted.

Stage 3: DOM Output

tag.toElement(opts) is the heavy path. It builds a live DOM element, wires every signal-attribute, prop, and content into an effect, registers connect/disconnect callbacks with the DOM tracker, and returns the element ready to be inserted into the document.

The function lives at content-tag.js .

flowchart TD
  S(["toElement()"]) --> A{"domElement cached?"}
  A -- yes --> R1["return cached"]
  A -- no --> B["validateContent"]
  B --> C["createElement(NS)"]
  C --> D["createLifecycle(element, persist)"]
  D --> E["For each attribute"]
  E --> E1{"Value type?"}
  E1 -- "on*+function" --> E2["addEventListener"]
  E1 -- "Signal" --> E3["lifecycle.signalEffect"]
  E1 -- "plain" --> E4["setAttribute"]
  E2 & E3 & E4 --> F["For each 'on' event"]
  F --> F1["addEventListener"]
  F1 --> G{"Has props?"}
  G -- yes --> G1["For each prop: Signal? signalEffect : assign"]
  G -- no --> H["For each content item"]
  G1 --> H
  H --> H1{"Item type?"}
  H1 -- "ContentTag/Literal/Comment" --> H2["recurse toElement, append"]
  H1 -- "Signal" --> H3["anchors + signalEffect -> reconcile"]
  H1 -- "plain" --> H4["createTextNode"]
  H2 & H3 & H4 --> I["lifecycle.finalize"]
  I --> J{"hasSignalContent?"}
  J -- yes --> K["markContentTracked"]
  J -- no --> L["cache domElement"]
  K --> L
  L --> R2["return element"]

Cache check

If #domElement is set, return it immediately. If the cached element is already in the DOM ( parentNode !== null ), this would silently move the node. showInvalid reports it.

if (this.#domElement) {
  if (this.#domElement.parentNode !== null) {
    showInvalid('toElement() called on a tag instance already in the DOM ...', ...);
  }
  return this.#domElement;
}
Why cache?

So that getDomElement() returns a stable reference, and so that re-calling toElement() on the same instance does not produce two independent DOM nodes that fight over the same signals.

Element creation

const element = this.namespace
  ? document.createElementNS(this.namespace, this.tagName)
  : document.createElement(this.tagName);
const lifecycle = createLifecycle({ element, persist });
let hasSignalContent = false;

SVG and MathML tags carry their namespace through the createSvgContentTag and createMathTag factories. For HTML tags, namespace is undefined and createElement is used.

Attribute wiring

Iterates the result of attributeArray() , a flat list of [name, value] pairs after camelCase-to-kebab conversion, nested-namespace expansion, style-object stringification, and class-array joining. For each pair:

Match Action
onclick , oninput , etc. with a function value element.addEventListener(name.slice(2), fn)
Signal value lifecycle.signalEffect(sig, apply, attrName)
Plain value element.setAttribute(name, value)

The signal-attribute apply function:

lifecycle.signalEffect(attrValue, (el, val) => {
  if (val === false || val === null || val === undefined) {
    el.removeAttribute(attrName);
  } else if (val === true) {
    el.setAttribute(attrName, '');   // bare attribute (disabled, checked, etc.)
  } else {
    el.setAttribute(attrName, String(val));
  }
}, attrName);

The effect runs once immediately to set the initial value, then re-runs whenever the signal changes. Inside the effect, el is the result of elementRef.deref() inside the lifecycle module. If the element has been garbage-collected, the effect self-stops.

Event handlers (the on object)

The on attribute attaches multiple event handlers via a single nested object:

t.button({ on: { click: handleClick, mouseenter: handleHover } }, 'Press me')

The loop calls element.addEventListener(eventName, handler) for each function value. No cleanup is needed. Event listeners are released when the element is garbage-collected.

Prop wiring

The prop key sets DOM properties directly, not attributes. This matters for things like input.value (DOM property reflects current state) vs. input[value] (attribute reflects initial state only):

t.input({ prop: { value: count } })  // input.value updates as count changes
t.input({ value: count.get() })      // frozen attribute set at construction time

isPropWritable validates each property against the live element before assignment. If the property exists on the prototype but is read-only, showInvalid reports it and the assignment is skipped. Otherwise:

if (propValue instanceof Signal) {
  lifecycle.signalEffect(propValue, (el, val) => { el[propName] = val; }, 'prop:' + propName);
} else {
  element[propName] = propValue;
}

Content wiring

For each item in the flattened content array (see content-tag.js ):

Case 1
Tag instance
Recurse into child.toElement() and append. The child has its own lifecycle. The parent does not own its cleanup.
Case 2
Signal value
Insert two comment-node anchors. Wire a signal effect that calls reconcile on every change. Set hasSignalContent so markContentTracked runs after.
Case 3
Plain value
Create a text node and append it.

The signal-content wiring:

if (node instanceof Signal) {
  hasSignalContent = true;
  const startAnchor = document.createComment('');
  const endAnchor = document.createComment('');
  element.append(startAnchor, endAnchor);
  lifecycle.signalEffect(node, (el, val) => {
    reconcile(el, startAnchor, endAnchor, Array.isArray(val) ? val : [val]);
  }, '(content)');
  continue;
}
Anchors persist for the element's lifetime

The two comment nodes are held only by the effect's closure. markContentTracked(element) tells the reconciler to never replace this element's children, even if a parent reconcile sees a fresh element with different children.

Lifecycle finalize

After all wiring, the lifecycle is finalized:

lifecycle.finalize({
  connectCallbacks: this.#connectedCallbacks,
  disconnectCallbacks: this.#disconnectedCallbacks,
  onCleared: () => { if (this.#domElement === element) { this.#domElement = null; } },
  onReconnect: () => { this.#domElement = element; },
});
if (hasSignalContent) {
  markContentTracked(element);
}
this.#domElement = element;
return element;
  • connectCallbacks. User-registered via addConnectedCallback . Fire on every insertion when persist is true; once otherwise.
  • disconnectCallbacks. User-registered via addDisconnectedCallback . Fire on every removal.
  • onCleared. Internal. Resets #domElement to null after removal so getDomElement() returns null.
  • onReconnect. Internal. Restores #domElement to the live element on re-insertion under persist mode.

Signal Anatomy

Before tracing the lifecycle module, here is how a Signal works. The full implementation is at signal.js .

Subscription via .get()

A Signal's subscribers are kept in a private Set on the instance. The mechanism that wires up a subscription is the module-scoped currentEffect reference, set during an effect() or computed() run:

get() {
  if (currentEffect !== null && !this.#subscribers.has(currentEffect)) {
    this.#subscribers.add(currentEffect);
    const sub = currentEffect;
    sub._cleanups.push(() => this.#subscribers.delete(sub));
  }
  return this.#value;
}
  • Calling .get() outside an effect or computed registers no subscription. It's just a read.
  • Calling .get() twice in the same effect is idempotent. The has(currentEffect) check prevents duplicates.
  • The cleanup function is pushed to the effect's _cleanups array, which the track helper drains and resets on each re-run.
.value and .toJSON() never subscribe

.value (getter) and .toJSON() both return this.#value directly. Reading .value inside an effect does not create a dependency. .toString() calls .get() , so template literals inside reactive contexts do track.

Writes and the microtask flush

.set(next) at signal.js compares via Object.is and bails on equality. Otherwise it updates the value and notifies subscribers:

sequenceDiagram
  participant U as User code
  participant S as Signal
  participant Q as pending Set
  participant Mt as queueMicrotask
  participant E as effect.run
  U->>S: .set(next)
  S->>S: Object.is(next, current)?
  alt equal
    S-->>U: return early
  else changed
    S->>S: value updated
    loop each subscriber
      alt subscriber is effect
        S->>Q: scheduleRun(fn)
        S->>Mt: queueMicrotask(flush)
      else subscriber is computed.update
        S->>E: update() synchronously
      end
    end
    S-->>U: return
    Mt->>Q: flush()
    loop each pending fn
      Q->>E: run()
    end
  end

Effects are batched. Multiple .set() calls in the same synchronous turn coalesce into a single re-run per effect because pending is a Set .

Computed updates run synchronously. This is intentional. A computed reading a.get() + b.get() must always be consistent with the latest values of a and b .

Error isolation in batches

flush() wraps each effect run in try/catch and re-throws via queueMicrotask . One effect's thrown error does not abort the batch. Every queued effect still runs.

Loop guards

flush() tracks re-queue counts per effect via a runCounts Map. After MAX_EFFECT_LOOPS = 100 re-queues for the same effect in one flush pass, it fires console.error and stops re-running that effect. A separate flushCount counter catches async flush loops: after MAX_FLUSHES = 500 consecutive flushes, it fires console.error and clears the pending set.

effect()

effect(fn) at signal.js guards against misuse before delegating to an internal createEffect(fn) helper. If called inside a running effect or computed body, it fires a throttled error because a new effect is started on every re-run without stopping the old one.

export function effect(fn) {
  if (inComputedFn) {
    throttledError('effect-in-computed', 'kensington: effect() called inside a computed or transform callback...');
  } else if (currentEffect !== null) {
    throttledError('effect-in-effect', 'kensington: effect() called inside an effect callback...');
  }
  return createEffect(fn);
}

createEffect(fn) is the shared implementation:

function createEffect(fn) {
  if (ssrDepth > 0) {
    return { pause() {}, resume() {}, stop() {} };
  }
  let paused = false;
  let destroyed = false;
  function run() {
    if (paused) { return; }
    track(run, fn);
  }
  run._cleanups = [];
  run._isEffect = true;
  run();
  return {
    pause() {
      paused = true;
      pending.delete(run);
      for (const cleanup of run._cleanups) { cleanup(); }
      run._cleanups = [];
    },
    resume() {
      if (destroyed) { return; }
      paused = false;
      run();
    },
    stop() {
      this.pause();
      destroyed = true;
    },
  };
}

The three returned methods give the caller control:

pause

Drains _cleanups (unsubscribing from every signal) and removes itself from pending . The effect won't re-run until resume() is called.

resume

Calls run() immediately, re-tracking subscriptions to every signal read inside it. No-op if destroyed is true.

stop() calls pause() and sets destroyed = true , making resume() a permanent no-op. This is the teardown path when an element is removed without persist mode.

_internalEffect(fn) is identical to the internal createEffect path but skips the effect-in-effect and effect-in-computed warning checks. The lifecycle module uses it because it legitimately creates effects inside running effects during reconcile, and those effects are correctly managed by dom-tracker.

computed()

computed(fn) at signal.js creates a Signal whose value is derived from other signals. Updates are synchronous (unlike effects).

Under ssrDepth > 0 , fn() runs once with no currentEffect set, so source .get() calls do not register a subscription. The returned Signal carries the snapshot value and never updates. This prevents per-request computed calls from leaking subscribers onto module-level signals that outlive the request.

Auto-dispose: when a computed's last subscriber is removed, a sleep callback unsubscribes from all sources and freezes the value. On the next .get() inside a reactive context, a wake callback re-runs fn() and re-subscribes to sources. This means an explicit .stop() call is rarely needed. When the parent effect re-runs and clears its subscriptions, the inner computed auto-sleeps and releases its source subscriptions automatically.

computed inside effect

signal() called inside a computed or effect callback emits a throttled error via filterStack (see filter-stack.js ). A new signal is created on every re-run, breaking the reconciler's snapshot fast path and leaving orphaned sleeping signals.

The Lifecycle Module

esm / lib / reactive / lifecycle.js

createLifecycle({ element, persist }) is a closure factory. Each call to toElement creates one. The returned object exposes two methods: signalEffect(sig, apply, label) and finalize({...}) .

This module is the only place that decides whether to pause or stop an effect on removal.

Internal state

export function createLifecycle({ element, persist }) {
  const stops = [];                              // pause-or-stop closures, one per signal effect
  const devIds = [];                             // effect IDs for devtools
  const resumables = persist ? [] : null;        // effect objects for resume() on reconnect
  const elementRef = new WeakRef(element);       // shared across every signalEffect
  function pauseOrStop(eff) {
    return () => persist ? eff.pause() : eff.stop();
  }
  function wireEffect(eff) {
    stops.push(pauseOrStop(eff));
    if (resumables !== null) { resumables.push(eff); }
  }
}

resumables is allocated only when persist is true. The common non-persist case has zero overhead from it.

signalEffect

signalEffect(sig, apply, label) {
  markNextEffectAsBinding(label);   // devtools: categorise as DOM binding with this label
  const eff = _internalEffect(() => {
    const el = elementRef.deref();
    if (!el) { eff.stop(); return; }  // element collected; self-stop
    apply(el, sig.get());
  });
  notifyEffectElement(eff._devId, element);  // devtools: link effect to element
  wireEffect(eff);
  return eff;
}

The effect runs once immediately when created, applying the initial signal value. On subsequent runs, it dereferences the WeakRef. If the element has been garbage-collected, the effect self-stops. No zombie subscriptions.

_internalEffect is used here instead of effect because lifecycle.js legitimately creates effects while other effects are running (during reconcile). The effect-in-effect guard in the public effect() export would fire spuriously.

WeakRef is the GC safety net

If a user creates a tag, calls toElement , and then drops every reference without ever inserting the element, the element is eligible for GC. Without WeakRef, the Signal's subscriber set would hold the effect closure, which would hold the element by reference. WeakRef breaks that cycle.

finalize

finalize registers the stop chain and (if needed) the connect callback with dom-tracker. Two branches:

persist: false (default)

On removal, every effect's stop() is called. Permanent teardown. Disconnect callbacks fire once. Connect callback fires once on first insertion only.

persist: true

On removal, every effect's pause() is called. The stop chain rebuilds for the next cycle via reFireAndRegister. On reconnect, every effect's resume() is called and the connect callback re-fires.

The disconnect chain

function registerDisconnectChain() {
  trackForStop(element, () => { for (const stop of stops) { stop(); } }, devIds);
  if (onCleared) { addOnStop(element, onCleared); }
  for (const fn of disconnectCallbacks) {
    addOnStop(element, () => fn.call(element, element));
  }
}

trackForStop registers the first link: running every signal effect's pauseOrStop closure. addOnStop appends to that chain: first onCleared (which resets the tag's #domElement cache), then each user-registered disconnect callback.

The persist rebuild

When persist is true, the chain rebuilds every cycle so disconnect callbacks fire on every removal, not just the first:

if (persist) {
  const reFireAndRegister = () => {
    trackForStop(element, () => {});
    if (onCleared) { addOnStop(element, onCleared); }
    for (const fn of disconnectCallbacks) {
      addOnStop(element, () => fn.call(element, element));
    }
    addOnStop(element, reFireAndRegister);  // self-perpetuates
  };
  addOnStop(element, reFireAndRegister);
}

The connect path

const needsConnect = persist || connectCallbacks.length > 0;
if (needsConnect) {
  let firstConnection = true;
  trackForConnect(element, () => {
    if (!firstConnection) {
      if (onReconnect) { onReconnect(); }
      if (resumables !== null && resumables.length > 0) {
        for (const eff of resumables) {
          eff.resume();
          addOnStop(element, () => eff.pause());
        }
      }
    }
    firstConnection = false;
    for (const fn of connectCallbacks) { fn.call(element, element); }
  }, persist);
}

The shared callback-fire loop runs on both first connection and reconnection. Only the reconnect-specific work is gated on !firstConnection.

The DOM Tracker

esm / lib / reactive / dom-tracker.js

A shared MutationObserver watches document.documentElement for any subtree mutation. When tracked elements are added or removed, it fires registered callbacks. This is what closes the loop between "element removed from the DOM" and "effects stop, signals unsubscribe."

The entries map

const entries = new WeakMap();
const trackedRefs = new Set();
const trackedCleanup = new FinalizationRegistry(ref => trackedRefs.delete(ref));
const contentTracked = new WeakSet();

entries is a WeakMap keyed by element. Each entry holds a stop function, an optional connect function, and a persist flag. A parallel trackedRefs Set (of WeakRefs) supports the iteration in visit() . The FinalizationRegistry removes dead WeakRefs as elements are collected so the trackedRefs.size short-circuit stays approximately accurate.

Why WeakMap + a parallel ref set?

A plain Map would pin every tracked element by key, so an element produced by toElement() and then dropped without ever being inserted would never be collected. The WeakMap avoids that. trackedRefs provides the iterable needed by visit() without pinning elements.

The observer

function buildObserver() {
  if (observer !== null) { return; }
  observer = new MutationObserver(records => {
    if (trackedRefs.size === 0) { return; }  // skip when nothing is tracked
    for (const record of records) {
      for (const node of record.removedNodes) { if (!node.isConnected) { stopRemoved(node); } }
      for (const node of record.addedNodes)   { fireConnected(node); }
    }
  });
  observer.observe(document.documentElement, { childList: true, subtree: true });
}

Built lazily on the first trackForStop or trackForConnect call. There is exactly one for the whole document. The trackedRefs.size === 0 short-circuit means mutation records are skipped without any per-record work when nothing is tracked.

The stopRemoved call is guarded by if (!node.isConnected) . A node removed and immediately reinserted in the same mutation batch will be connected again when the observer fires, so its effects must not be stopped.

The visit helper

visit(node, fn) handles two cases for a mutation record's node: the node itself might be tracked, or it might be an ancestor of one or more tracked elements:

function visit(node, fn) {
  const own = entries.get(node);
  if (own !== undefined) {
    fn(node, own);
    // Don't return — also process tracked child elements so that effects on
    // descendants are paused or stopped together with the parent.
  }
  if (node.nodeType !== 1) { return; }
  for (const ref of [...trackedRefs]) {   // snapshot to avoid mutation during iteration
    const el = ref.deref();
    if (el === undefined) { trackedRefs.delete(ref); continue; }
    if (el === node) { continue; }
    if (own !== undefined && el.nodeType !== 1) { continue; }  // skip comment anchors on persist parents
    if (node.contains(el)) {
      const entry = entries.get(el);
      if (entry !== undefined) { fn(el, entry); }
    }
  }
}
visit() does not return early

Even when the node itself has an entry, visit() continues to check trackedRefs for child elements. This ensures that effects on descendants (for example, a checked=signal attribute on an <input> inside a persist-mode parent <li> ) are paused or stopped together with the parent.

API surface

Export Purpose
trackForStop(el, fn, devIds) Register the initial stop function and associated devtools effect IDs.
trackForConnect(el, fn, persist) Register the connect callback. persist controls re-fire and entry survival after removal.
addOnStop(el, fn) Append to the stop chain. No-op if stop is not set.
markContentTracked(el) Flag an element as owning signal-content anchors.
isTracked(el) Does this element have an active stop registration?
isContentTracked(el) Was this element flagged via markContentTracked?
stopTracked(el) Force synchronous teardown. Used by the reconciler for discarded fresh nodes.
stopRemoved(node) Called by the MutationObserver. Calls visit() to find and stop all tracked entries for node or its descendants.

Removal Flow

An element is removed when something calls element.remove() , parent.removeChild(element) , parent.replaceChildren(...) , etc. The browser fires a mutation record. The shared MutationObserver picks it up.

sequenceDiagram
  participant U as User code
  participant B as Browser
  participant Mo as MutationObserver
  participant T as DOM tracker
  participant L as Lifecycle stops
  participant S as Signals
  U->>B: element.remove()
  B->>Mo: MutationRecord (removedNodes)
  Mo->>T: stopRemoved(node)
  T->>T: visit(node, fn)
  Note over T: For node itself AND any tracked descendants
  T->>T: clearStop(entry, el)
  T->>L: stop()
  Note over L: pauseOrStop for each effect, then onCleared, then disconnectCallbacks
  L->>S: eff.stop() or eff.pause()
  Note over S: Drains _cleanups, removes from subscribers
  1. Browser fires the MutationRecord. removedNodes contains the directly-removed node. The tracked element may be that node or a descendant.
  2. stopRemoved(node). Calls visit(node, fn) which finds the tracked entry for the node or any tracked descendant.
  3. clearStop(entry, el). Deletes entry.stop. If not persisted, also deletes connect and persist. If both halves are gone, removes the entry entirely.
  4. The captured stop function runs. This is the chained closure built via trackForStop and every addOnStop.
  5. For each signal effect: eff.pause() or eff.stop(). Driven by the persist flag. pause() drains _cleanups (unsubscribing from each Signal); stop() does the same and sets destroyed.
  6. onCleared runs. Resets the tag's #domElement cache to null.
  7. Each user disconnect callback runs. In registration order.
Removal vs stopTracked()

The removal path above is triggered automatically by the MutationObserver. stopTracked(el) at dom-tracker.js does the same teardown synchronously without waiting for a mutation record. The reconciler calls this on discarded fresh nodes before returning to the caller.

Persist Mode

By default, removal is permanent. Effects stop, the tag's #domElement cache clears, and disconnect callbacks fire. If the element is re-inserted later, the effects are gone and the signal subscriptions must be rebuilt by calling toElement() again.

The persist tag option changes this. Effects pause instead of stop. The tag's #domElement restores when the element returns. The disconnect-callback chain rebuilds so it fires on every cycle, not just the first.

This is the pattern for elements that move between containers without losing identity: tabs that swap, modals that hide and reshow, custom elements whose connectedCallback fires multiple times.

sequenceDiagram
  participant U as User code
  participant E as Element
  participant Mo as MutationObserver
  participant L as Lifecycle
  participant Sg as Signals
  Note over E,L: Initial render with persist:true
  U->>E: parent.append(element)
  Mo->>L: fireConnected, first connect cb run
  Note over E: Signal updates, effects re-run normally
  U->>Sg: signal.set(v)
  Sg->>L: scheduled effect runs
  Note over E,L: First removal
  U->>E: parent.removeChild(element)
  Mo->>L: stopRemoved, each eff.pause()
  L->>L: onCleared, disconnectCallbacks
  L->>L: reFireAndRegister installs new stop chain
  Note over E,L: Re-insertion
  U->>E: parent.append(element)
  Mo->>L: fireConnected
  L->>L: onReconnect, eff.resume() for each effect
  L->>L: addOnStop(eff.pause) re-arm for next removal
  L->>L: connect callbacks fire again

The persist invariants

  1. Pause, don't stop. Every signal effect is captured in resumables. On removal, pauseOrStop picks pause(). The effect closure still exists, just unsubscribed.
  2. Disconnect callbacks re-arm. reFireAndRegister installs a fresh stop chain after each removal so the next removal fires them again.
  3. Reconnect resumes. eff.resume() calls run(), which re-tracks subscriptions and applies the current signal value. Any updates that happened during the gap are visible immediately.
  4. Resume wires its own pause. Right after eff.resume(), the lifecycle adds () => eff.pause() to the new stop chain. The cycle continues.
  5. Connect callbacks fire every cycle. On first insertion and on every reconnect.

persist: false vs. persist: true

persist: false persist: true
On removal: effects eff.stop(). Permanent eff.pause(). Temporary
On removal: connect entry Deleted from entries map Survives in entries map
On removal: disconnect callbacks Fire once total Fire on every removal cycle
On reinsert: connect callbacks Do not fire (entry gone) Fire every cycle
On reinsert: signal state Subscriptions gone; tag must be rebuilt eff.resume() reconnects with current value
Memory footprint Lower. resumables is null Higher. Effects and chain survive
When NOT to use persist

If connectedCallback creates a fresh element each time (the common Web Components pattern), persist is wrong. The old paused effects become orphaned when the new element replaces them. Use persist: true only when you hold the same DOM node across reconnections.

Reconciliation

esm / lib / reactive / reconcile.js

Every signal-content update calls reconcile at reconcile.js . The function patches the DOM in place rather than tearing it down and rebuilding. It handles both single-value and array-valued signals. Non-arrays are wrapped as [val] before passing in, so the algorithm only handles the array case.

Reconciliation runs between a pair of comment anchors set up at element construction. The anchors give the function stable boundaries: startAnchor.nextSibling is the first child to consider, endAnchor is the sentinel.

Keyed matching

Each item can have a data-key attribute. Keyed items match against existing children with the same key, not the same positional index. This enables efficient reordering:

export function reconcile(parent, startAnchor, endAnchor, newItems) {
  const oldNodes = new Map();
  let node = startAnchor.nextSibling;
  while (node !== endAnchor) {
    const key = node.dataset?.key;
    if (key !== undefined) { oldNodes.set(key, node); }
    node = node.nextSibling;
  }
  // ...
}

Without a key, items are matched positionally and recreated if the shape differs.

Snapshot fast path

Once a keyed match is found, the reconciler checks a structural snapshot of the previous render. A WeakMap keyed by DOM node holds the last (attributes, content) pair that produced it. If the new tag's attributes and content are value-equal to the snapshot, the entire itemToNode(item) and syncNode chain is skipped. The existing DOM node is reused unchanged.

if (snapshotMatches(snapshots.get(old), item)) {
  targetNode = old;   // skip itemToNode() and syncNode(); reuse existing
} else {
  targetNode = syncNode(old, itemToNode(item));
  recordSnapshot(targetNode, item);
}

valueEqual compares plain objects and arrays structurally, recurses into ContentTag instances (matching on tagName + attributes + content), and falls back to reference equality for everything else (functions, Signal, LiteralTag, CommentTag, DOM nodes, Date, class instances).

Why value equality, not reference equality

The natural pattern arr.map(item => t.li({ class: item.cls }, item.label)) allocates a fresh attribute object literal on every render. Reference equality on those literals would always miss. Value equality detects the structurally identical literal and skips the rebuild without requiring the developer to memoize.

A stable Signal reference hits the fast path via reference equality. A fresh closure or fresh LiteralTag on each render does not. The snapshot is recorded only on the non-fast-path branch, so an item that keeps hitting the fast path retains its original snapshot indefinitely.

Circular import

reconcile.js imports ContentTag for the instanceof check in valueEqual , and content-tag.js imports reconcile for its signal-content effect. Both sides use the other inside function bodies at call time, not at module-load time, so ESM live bindings resolve correctly. Rollup emits a CIRCULAR_DEPENDENCY warning that is informational only.

syncNode

syncNode(existing, fresh) handles a matched pair. If the node types differ, the fresh node replaces the existing one. If they're both text nodes, only nodeValue is patched. If they're both elements, it applies fresh attributes and recursively syncs child pairs.

The guards

Why guards are needed

The fresh node passed to syncNode is the result of calling itemToNode(item) , which calls item.toElement() . That fresh node is fully wired with its own signal effects pointing at the fresh element. Patching attributes or children naively would corrupt the live element's effects.

// Attribute guard
if (!isTracked(existing)) {
  for (const attr of oldAttrNames) {
    existing.removeAttribute(attr);
  }
}
// Content guard
if (!isContentTracked(existing)) {
  // positional sync of child nodes
}
  • isTracked(existing). If true, the existing element has signal-managed attributes. Don't remove attributes that weren't on the fresh node. The signal effects haven't yet applied their initial values to the fresh element when reconcile inspects it.
  • isContentTracked(existing). If true, the existing element holds signal-content comment anchors. Don't patch its children at all. Replacing the anchors would break the live content effects whose closures still reference them.

After patching, stopTracked(fresh) tears down the discarded fresh node's effects. This is called synchronously (not waiting for the MutationObserver) to close the window where a just-removed node could still respond to signal changes.

Insertion, reuse, leftover cleanup

The main reconcile loop walks newItems in order:

  1. null , undefined , and false items are skipped.
  2. If the item has a key and matches an existing keyed node, call syncNode. The result is either the patched existing node or the fresh node if types diverged.
  3. If no match (or no key), build a new node via itemToNode.
  4. If cursor === targetNode , advance. The node is already in position. Otherwise call parent.insertBefore(targetNode, cursor) to slide it into place.
  5. After the loop, every node between cursor and endAnchor is leftover. Remove them and call stopRemoved synchronously to stop their signal effects.
  6. Every entry remaining in the oldNodes map is a keyed node whose key was not in newItems. Remove and stop them too.

SSR and Hydration

esm / lib / render / hydration.js

On the server (or any environment without a real DOM), reactive subscriptions must not be created. They would have nothing to update and no cleanup path, so they would leak immediately. Both effect() and computed() consult the ssrDepth counter at signal.js .

The SSR bypass

export function effect(fn) {
  if (ssrDepth > 0) {
    return { pause() {}, resume() {}, stop() {} };  // no-op stub
  }
  // ... normal path ...
}
export function computed(fn) {
  if (ssrDepth > 0) {
    const s = new Signal(fn());     // value snapshot, no subscriptions
    derivedSignals.add(s);
    return s;
  }
  // ... normal path ...
}

Inside an SSR call, effect() returns a no-op stub and computed() returns a frozen-value Signal. No subscriptions are created in either case. tag.toString() still reads signal values via .get() (which works fine without a current effect) and produces a static HTML snapshot.

Why computed() needs the bypass too

Without it, a per-request computed or transform reading a module-level signal would register an update closure in that signal's subscriber set. The signal lives across requests, so each render leaves one dead subscriber behind. Over time the subscriber set, and the time spent iterating it on every .set() , grows without bound.

renderForHydration

renderForHydration(fn, state, name) in hydration.js wraps a component for isomorphic rendering. On the server, it increments ssrDepth , invokes fn(state) to produce a tag instance, calls toString() , embeds the resulting HTML alongside a JSON state block, and decrements ssrDepth in finally. The caller is responsible for inserting the resulting HTML into the page.

On the client, renderForHydration produces a placeholder element that registerComponents later replaces with the live DOM version.

registerComponents + the JSON block

On the client, registerComponents({ name: fn }) reads the JSON block embedded by the server render, looks up each registered component by name, and runs fn(state) to produce a fresh tag instance. That instance's toElement() creates the live DOM tree with signal effects, which then replaces the SSR-rendered HTML in the document.

This is "remove and replace" hydration, not "reuse and attach." The SSR HTML serves time-to-first-paint. The live version takes over once JS is ready.

Why not reuse?

Reuse hydration requires the SSR HTML and the client's tag tree to match exactly. The current strategy avoids that constraint at the cost of one extra DOM swap per component.

Invariants

The rules that hold across every code path. Violations are bugs.

  1. validationLevel: 'off' never throws on runtime input. All validation routes through show-invalid.js , which is a no-op at 'off'. Only hard invariants (createTag called with a non-string tagName, etc.) throw unconditionally.
  2. Signal values are accepted everywhere a plain value is accepted. attributeValueIsValid returns true for Signals without inspecting them. Resolution happens at render time.
  3. .value and .toJSON() do not subscribe; .get() and .toString() do. The asymmetry is intentional. Use .value inside an effect when you need the current value but do not want to create a dependency.
  4. The persist mechanism lives entirely in lifecycle.js . No other file decides between pause() and stop(). dom-tracker knows about persist only to decide whether to preserve the connect/persist entry fields after stop-cleanup.
  5. The reconcile guards must never be bypassed. isTracked prevents attribute-strip on signal-managed elements. isContentTracked prevents child-patch on elements holding signal-content anchors. stopTracked is called synchronously after every .remove() in the reconciler so effects stop before the MutationObserver fires.
  6. Effects batch via microtasks; computed updates are synchronous. Multiple .set() calls in the same turn coalesce into one effect re-run. Computed signals see consistent inputs because their updates happen inline with the write.
  7. The DOM tracker has exactly one observer for the whole document. Built lazily on the first trackForStop or trackForConnect call.
  8. WeakRef is the GC safety net for signal effects on detached elements. If an element is never inserted and is garbage-collected, the next signal write triggers an effect that finds ref.deref() returning undefined and self-stops.
  9. visit() does not return early when it finds the node itself. It continues to check trackedRefs for descendants. This ensures child effects are paused or stopped with the parent.
  10. _internalEffect is for library-internal use only. It skips the effect-in-effect and effect-in-computed guard checks. Only lifecycle.js should call it.

Where to look

If you're fixing a bug or adding a feature, here's where the change probably belongs.

If you're working on... Look at...
A new attribute type or validation rule validate.js . Either attributeValueIsValid or validateAttributeByType
HTML output formatting (indentation, encoding) serialize.js + stringify-content-array.js
DOM property vs attribute, event handler wiring content-tag.js . The toElement dispatch
Signal subscription semantics (.get, .set, .value) signal.js . The Signal class
Effect lifecycle (pause, resume, stop, batching) signal.js . effect(), _internalEffect(), createEffect(), flush()
Persist mode (pause on removal, resume on reconnect) lifecycle.js . The entire file
When effects stop or connect callbacks fire dom-tracker.js . stopRemoved and fireConnected
Signal-array DOM patching reconcile.js . Particularly syncNode and the guards
SSR or hydration behavior hydration.js + the ssrDepth counter in signal.js
A new tag-class flavor (e.g. for custom output) . Extend ContentTag
Generated Kensington class behavior build-kensington.js . The template that emits esm/kensington.js
Before you change a tracked path

Almost every browser test in signals.spec.js exercises one of the paths above. Running npm run test-browser after any change to signal.js, lifecycle.js, dom-tracker.js, or reconcile.js is the fastest way to catch regressions.