API

Builder API

The fluent builder creates components through an immutable chain. Each step returns a new builder instance, accumulating type information:

define("x-my-comp") // ComponentBuilder (entry point)
  .withProps((p) => ({ ... })) // + prop types
  .withRefs((r) => ({ ... })) // + ref types
  .setup((ctx) => { ... }); // terminates chain, registers element

withProps(), withRefs(), and withContexts() are optional and can appear in any order. setup() ends the chain: it calls customElements.define under the hood and returns a typed constructor.

define

define(name) / define(name, setup)

Entry point for creating a component. Returns a ComponentBuilder when called with just a name. For simple components, pass setup directly as the second argument:

const Logger = define("x-logger", (ctx) => {
  console.log("connected:", ctx.host.tagName);
});

withProps

Declare validated, reactive attributes via withProps(). Each prop becomes:

  • An observed HTML attribute (auto-synced via attributeChangedCallback)
  • A Nano Stores WritableAtom at ctx.props.$propName
  • A typed getter/setter on the element instance

Prop names are camelCase in JS and automatically mapped to kebab-case HTML attributes: tabIndextab-index, isOpenis-open. The property setter reflects back to the attribute.

Four built-in validators coerce raw attribute strings to typed values:

ValidatorCoercionnull attr
p.string()String(val)""
p.number()Number(val)0
p.boolean()"true" / ""true, "false"falsefalse
p.oneOf(opts)Picklist enum, throws on invalidthrows
.withProps((p) => ({
  title: p.string(),
  count: p.number(),
  open:  p.boolean(),
  size:  p.oneOf(["s", "m", "l"]),
}))

Each validator accepts an optional fallback. Pass null to make the prop nullable (inferred type becomes T | null):

.withProps((p) => ({
  label: p.string(null), // string | null
  size:  p.oneOf(["s", "m", "l"], null), // "s" | "m" | "l" | null
}))

Props use Standard Schema internally, so any compatible validator (Valibot, Zod, ArkType) works as a custom prop schema.

JSON props

For complex data that doesn’t fit into a string attribute, use p.json() with a Standard Schema validator:

import * as v from "valibot";

.withProps((p) => ({
  items: p.json(v.array(v.object({ id: v.number(), name: v.string() })), []),
  config: p.json(v.object({ theme: v.string() })), // defaults to null
}))

JSON props differ from attribute-backed props:

  • Not observed: not in observedAttributes, no attributeChangedCallback
  • Setter writes to atom directly: no attribute created in the DOM
  • Hydrated once on connect: reads from a <script type="application/json"> tag, falls back to a kebab-case attribute
<!-- preferred: script tag (no escaping needed) -->
<x-list>
  <script type="application/json" data-prop="items">
    [{ "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" }]
  </script>
</x-list>

<!-- also works: inline attribute -->
<x-list items='[{"id":1,"name":"Alice"}]'></x-list>

After hydration, set programmatically:

el.items = [{ id: 3, name: "Charlie" }]; // updates atom, no DOM attribute

Property-only props

Set attribute: false to create a prop that exists only as a JS property and a Nano Stores atom, not an HTML attribute. Defined in the constructor, available immediately after document.createElement():

.withProps((p) => ({
  label: p.string(), // attribute-backed
  value: { schema: p.string(""), attribute: false }, // property-only
}))

This is useful when:

  • The value is large or complex (editor content, serialized state): writing kilobytes to a DOM attribute is wasteful
  • A component wraps an imperative resource (CodeMirror, canvas) and exposes its state as .value
  • A parent uses ctx.bind() on a child: bind() needs the .value property to exist from construction time, before setup runs

The full PropDef shape:

type PropDef<T> = {
  schema: StandardSchemaV1<unknown, T>;
  attribute?: boolean; // default: true
  get?: (host: HTMLElement, key: string) => unknown; // custom hydration reader
};

withRefs

Declare typed element references. Refs query the component’s own DOM, skipping elements inside nested custom elements by default.

r.one() returns a single element (throws if missing), r.many() returns an array (throws if empty). When you pass a tag name, it’s used for both type inference and runtime validation:

.withRefs((r) => ({
  trigger: r.one("button"), // HTMLButtonElement, validated
  items:   r.many("li"), // HTMLLIElement[], validated
}))

By default, refs match [data-ref="name"]. Non-tag strings (containing ., #, [, etc.) are treated as CSS selectors:

.withRefs((r) => ({
  custom: r.one(".my-trigger"), // Element
  typed:  r.one<HTMLButtonElement>(".my-trigger"), // HTMLButtonElement (type-only)
  any:    r.many<HTMLElement>(), // HTMLElement[] (no tag validation)
}))

Ref ownership

By default, a component owns only its direct refs: elements inside nested custom elements are skipped for proper encapsulation:

<x-parent>
  <span data-ref="title">owned by x-parent</span>
  <x-child>
    <span data-ref="subtitle">owned by x-child, skipped by x-parent</span>
  </x-child>
</x-parent>

With slot-based composition (e.g. Astro components passed into structural wrappers), refs may end up inside other custom elements even though they conceptually belong to the outer component. To claim ownership, prefix the ref with the component’s tag name, <custom-element>:<ref-name>:

<x-code-example>
  <x-resizable-panes>
    <button data-ref="x-code-example:tabs">owned by x-code-example</button>
  </x-resizable-panes>
</x-code-example>

The JS definition stays the same: each ref automatically checks both [data-ref="name"] (direct ownership) and [data-ref="x-component:name"] (explicit ownership). You can mix both in the same component.

withContexts

Declares required contexts on the builder. Setup is deferred until all contexts resolve:

import { createContext } from "nanotags/context";

const tabsCtx = createContext<TabsAPI>("tabs");

define("x-tab")
  .withContexts({ tabs: tabsCtx })
  .setup((ctx) => {
    ctx.contexts.tabs.register(ctx.host);
  });

If a context never resolves (no provider ancestor), setup never runs. For dynamic or conditional access, use consume() directly. See the Context API guide.

setup

The setup() function receives a SetupContext object and runs when the component connects. It wires up behavior: event listeners, reactive effects, bindings, and cleanup.

Mixin (return value)

Returning an object from setup() assigns its members to the element instance, fully typed on the constructor:

const Timer = define("x-timer").setup((ctx) => {
  let id: number;
  return {
    start() { id = setInterval(() => ctx.emit("tick"), 1000); },
    stop()  { clearInterval(id); },
  };
});

document.body.appendChild(new Timer());
document.querySelector("x-timer")!.start();

Mixin members are available only after the element is connected (i.e. after setup() runs).

Setup Context

The setup() function receives a context object (ctx) with properties and methods for building reactive components.

host

The component’s HTMLElement itself. Useful for reading layout, appending content, or passing to external APIs.

ctx.host.classList.add("active");

props

Reactive prop stores, each prefixed with $. Every prop declared via withProps() becomes a Nano Stores WritableAtom:

ctx.props.$count.get(); // read current value
ctx.props.$count.set(42); // update value
ctx.effect(ctx.props.$count, (val) => { /* react */ });

refs

Resolved element references declared via withRefs():

ctx.refs.trigger // HTMLButtonElement
ctx.refs.items // HTMLLIElement[]

contexts

Resolved context values when withContexts() is used:

ctx.contexts.tabs.register(ctx.host);
ctx.effect(ctx.contexts.tabs.$active, (active) => { /* ... */ });

effect

effect(store, callback) / effect([storeA, storeB], callback)

Subscribe to one or more Nano Stores atoms. Callback fires immediately with current value(s). Unsubscribes on disconnect.

ctx.effect(ctx.props.$count, (count) => {
  ctx.refs.display.textContent = String(count);
});

ctx.effect([storeA, storeB], (a, b) => {
  /* ... */
});

bind

bind(store, element, options?)

Two-way binds a DOM control to a Nano Stores atom. The store is the source of truth: the control is set from the store on bind.

No options. Auto-detects control type:

ControlPropertyListens to
input[type=checkbox].checkedchange
input[type=number|range].valueAsNumberinput
input / textarea.valueinput
select.valuechange
Custom element with .value.valuechange
ctx.bind($name, ctx.refs.nameInput);
ctx.bind($agreed, ctx.refs.checkbox);

With options. Bind to any element property. Omit event for one-way (store → element):

ctx.bind($theme, el, { prop: "theme" }); // one-way
ctx.bind($val, el, { prop: "value", event: "change" }); // two-way

When binding to a custom element, .value (or the target property) must be defined via withProps(), not as a mixin return value. Props are available from the constructor, while mixin members only exist after connectedCallback.

on

on(target, type, listener, options?)

Attach event listeners with automatic cleanup. Accepts a single element, an array, document, or window. Event types are fully inferred for each target:

ctx.on(ctx.refs.trigger, "click", (e) => { /* ... */ });
ctx.on([...ctx.refs.items], "mouseenter", (e) => { /* ... */ });
ctx.on(document, "keydown", (e) => { /* ... */ });

emit

emit(event) / emit(name, detail?, options?)

Dispatch an existing Event or construct and dispatch a bubbling CustomEvent

ctx.emit(new CustomEvent("reset"));
ctx.emit("change", { value: 42 });

getElement

getElement(selector) / getElement(root, selector)

Asserts that the element exists and returns it with the correct type, no null checks or casting. Tag-name selectors narrow the return type automatically. Throws if nothing matches.

ctx.getElement("input"); // HTMLInputElement (throws if missing)
ctx.getElement(customParent, ".item"); // Element
// type-only, no runtime tag check
ctx.getElement<"input">(customRoot, ".my-input"); // HTMLInputElement

getElements

getElements(selector) / getElements(root, selector)

Works the same as getElement() but returns all matching elements as a typed Array (not a NodeList). Throws if none found.

ctx.getElements("button"); // HTMLButtonElement[]
// type-only, no runtime tag check
ctx.getElements<"input">(customRoot, ".field"); // HTMLInputElement[]

Both methods are primarily useful for dynamic queries inside renderList() update callbacks or other imperative code. For static element references, prefer refs.

For nullable results, use ctx.host.querySelector()/querySelectorAll() directly.

onCleanup

onCleanup(callback)

Register arbitrary teardown logic to run on disconnect:

const raf = requestAnimationFrame(tick);
ctx.onCleanup(() => cancelAnimationFrame(raf));

Context API

Cross-component communication for parent-child relationships. Import from the separate nanotags/context entry point (~0.4 KB).

import { createContext } from "nanotags/context";

For a conceptual overview, see the Context API guide.

createContext

createContext<T>(name?)

Creates a typed context key with provide() and consume() methods. The optional name is used as the Symbol description for debugging.

type TabsAPI = {
  register: (el: Element) => void;
  $active: WritableAtom<string>;
};

const tabsContext = createContext<TabsAPI>("tabs");

context.provide

contextKey.provide(ctx, value)

Registers the component as a context provider. Any descendant that consumes this context key will receive value.

The ctx parameter requires host (HTMLElement) and onCleanup; the setup context satisfies this. The provider’s event listener is auto-cleaned on disconnect.

define("x-tabs").setup((ctx) => {
  const $active = atom("");
  tabsContext.provide(ctx, {
    register: (el) => { /* ... */ },
    $active,
  });
});

On connect, provide() also dispatches a context-provider event to resolve any pending consumers that connected before the provider.

context.consume

contextKey.consume(ctx, callback)

Requests the context value from the nearest ancestor provider. The callback receives the provided value.

define("x-tab").setup((ctx) => {
  tabsContext.consume(ctx, (tabs) => {
    tabs.register(ctx.host);
    ctx.effect(tabs.$active, (active) => { /* ... */ });
  });
});

If a provider is already connected, the callback fires synchronously. If not, the request is queued and resolved when the provider calls provide(). Pending requests are cleaned up on disconnect.

Rendering

Keyed reconciliation utilities for dynamic content. Import from the separate nanotags/render entry point (~0.4 KB).

import { render, renderList } from "nanotags/render";

Both render() and renderList() own the entire container: any child not part of the current render cycle is removed. Containers must be dedicated to rendered content.

render

render(container, template, options?)

Single-item rendering. Options are optional. Omit for static templates (loading spinners, error states, empty placeholders):

render(container, loadingTpl); // static
render(container, profileTpl, { // data-driven
  data: user,
  update: (el, u) => { el.setAttribute("name", u.name); },
});

Switching templates replaces the previous element. Internally delegates to renderList() with a single-element array.

renderList

renderList(container, template, options)

Reconcile a data array against DOM by key. Creates, updates, removes, and reorders elements without recreating the whole list. Skips update when the item reference hasn’t changed (===).

<ul data-ref="list">
  <template data-ref="rowTpl">
    <li><span class="name"></span></li>
  </template>
</ul>
ctx.effect($users, (users) => {
  renderList(ctx.refs.list, ctx.refs.rowTpl, {
    data: users,
    key: (user) => user.id,
    update: (el, user) => {
      ctx.getElement(el, ".name").textContent = user.name;
    },
  });
});

Options:

OptionTypeDescription
datareadonly T[]Array of items to render
key(item: T, index: number) => string | numberUnique key per item
update(el: Element, item: T) => voidCalled on create and when the item reference changes

TypeScript

TypedEvent

TypedEvent<Target, Detail>

A type-only helper that narrows CustomEvent to a specific target and detail. Useful for defining type-safe custom events:

import type { TypedEvent } from "nanotags";

type TabsChangedEvent = TypedEvent<
  InstanceType<typeof XTabs>,
  { index: number }
>;

Combine with HTMLElementEventMap augmentation for app-wide type safety. See Typed custom events and Augmenting HTMLElementTagNameMap recipes.