A thin Web Components wrapper powered by Nano Stores reactivity. It leans on the platform—Custom Elements, standard DOM, regular CSS—instead of reinventing them. The result is a typed, reactive component model with automatic cleanup in under 2.5 KB.
- No Shadow DOM: markup stays in the regular DOM, styled with normal CSS
- Reactive props via Nano Stores atoms: subscribe when you need updates,
.get()when you don't - Typed fluent builder: props, refs, and contexts are fully inferred through the chain
- Automatic cleanup: event listeners, store subscriptions, and bindings are removed on disconnect
- Tree-shakeable:
nanotags/renderandnanotags/contextare separate entry points - Standard Schema: built-in validators plus any Standard Schema‑compatible library (Valibot, Zod, ArkType)
- Hydration-first: built for statically rendered markup. Pair with Astro, server-rendered HTML, or any static-first setup to hydrate lightweight interactive islands
<x-hello></x-hello>import { define } from 'nanotags';
define("x-hello", () => alert("Hello, world!"));For components with props and refs, use the fluent builder:
<x-counter count="0">
<span data-ref="display">0</span>
<button data-ref="increment">+1</button>
</x-counter>import { define } from "nanotags";
const Counter = define("x-counter")
.withProps((p) => ({
count: p.number(),
}))
.withRefs((r) => ({
increment: r.one("button"),
display: r.one("span"),
}))
.setup((ctx) => {
ctx.on(ctx.refs.increment, "click", () => {
ctx.props.$count.set(ctx.props.$count.get() + 1);
});
ctx.effect(ctx.props.$count, (value) => {
ctx.refs.display.textContent = String(value);
});
}); Made at Evil Martians, product consulting for developer tools.
- Installation
- Component Builder
- Setup Context
- Extra APIs
- Component Communication
- Tips & Tricks
- FAQ
- License
npm install nanotags nanostoresnanostores is a peer dependency.
define(name) returns a ComponentBuilder with an immutable fluent chain. Each step returns a new builder, accumulating type information:
define("x-my-comp") // ComponentBuilder (entry point)
.withProps((p) => ({ ... })) // + prop types
.withRefs((r) => ({ ... })) // + ref types
.setup((ctx) => { ... }); // terminates chain, registers elementwithProps, 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.
For simple components, pass setup directly as the second argument:
const Logger = define("x-logger", (ctx) => {
console.log("connected:", ctx.host.tagName);
});Declare reactive attributes via withProps. Each prop becomes:
- An observed HTML attribute (auto-synced via
attributeChangedCallback) - A Nano Stores
WritableAtomatctx.props.$propName - A typed getter/setter on the element instance
Four built-in validators coerce raw attribute strings to typed values:
| Validator | Coercion | null attr |
|---|---|---|
p.string() |
String(val) |
"" |
p.number() |
Number(val) |
0 |
p.boolean() |
"true" / "" → true, "false" → false |
false |
p.oneOf(opts) |
Picklist enum, throws on invalid | throws |
.withProps((p) => ({
title: p.string(),
count: p.number(),
open: p.boolean(),
size: p.oneOf(["s", "m", "l"]),
}))Pass null as fallback to make a 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 works as a custom prop schema.
For complex data, 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 → T | null
}))JSON props are not observed attributes. They hydrate once on connect from a <script type="application/json"> tag (preferred) or a kebab-case attribute, and the setter writes directly to the atom:
<x-list>
<script type="application/json" data-prop="items">
[{ "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" }]
</script>
</x-list>el.items = [{ id: 3, name: "Charlie" }]; // updates atom, no DOM attributeSet attribute: false to create a prop that lives only as a JS property and a Nano Stores atom, not an HTML attribute. Defined on the element in the constructor, available immediately after document.createElement():
.withProps((p) => ({
label: p.string(), // attribute-backed
value: { schema: p.string(""), attribute: false }, // property-only
}))Declare typed element references via withRefs. Refs query the component's own DOM, skipping elements inside nested custom elements 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)
}))Refs are scoped: elements inside nested custom elements are skipped. This keeps components independent.
For slot-based composition (e.g. Astro components passed into structural wrappers), prefix data-ref with the owning component's tag name to collect refs deeply:
<x-code-example>
<x-resizable-panes>
<button data-ref="x-code-example:tabs">...</button>
</x-resizable-panes>
</x-code-example>The JS definition stays the same: each ref automatically checks both [data-ref="name"] (shallow) and [data-ref="x-component:name"] (deep). You can mix owned and unowned refs freely.
Returning an object from setup assigns its members to the element instance, fully typed on the constructor. Mixin methods are available only after the element is connected (i.e. after setup runs):
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(); // typedThe setup function receives a context object (ctx) with the following properties and methods.
Properties:
ctx.host: the component'sHTMLElementctx.props: reactive prop stores, each prefixed with$(e.g.ctx.props.$count)ctx.refs: resolved element referencesctx.contexts: resolved context values (when usingwithContexts)
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) => { /* ... */ });Two-way binds a DOM control to a Nano Stores atom. The store is the source of truth.
No options. Auto-detects control type:
| Control | Property | Listens to |
|---|---|---|
input[type=checkbox] |
.checked |
change |
input[type=number|range] |
.valueAsNumber |
input |
input / textarea |
.value |
input |
select |
.value |
change |
Custom element with .value |
.value |
change |
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-wayNote: When binding to a custom element,
.value(or the target property) must be defined viawithProps, not as a mixin return value. Props are available from the constructor, while mixin members only exist afterconnectedCallback.bindneeds the property to be there immediately.
Attach event listeners with automatic cleanup. Accepts a single element, an array, document, or window. Event types are fully inferred for each target (HTMLElementEventMap, DocumentEventMap, WindowEventMap):
ctx.on(ctx.refs.trigger, "click", (e) => { /* ... */ });
ctx.on([...ctx.refs.items], "mouseenter", (e) => { /* ... */ });
ctx.on(document, "keydown", (e) => { /* ... */ });Dispatch an existing Event or construct and dispatch a bubbling CustomEvent:
ctx.emit(new CustomEvent("reset"));
ctx.emit("change", { value: 42 });Typed wrappers around querySelector/querySelectorAll that throw when nothing matches. Since nanotags targets static markup, a missing element is usually a bug.
ctx.getElement("input"); // HTMLInputElement (throws if missing)
// Custom parent, especially useful for rendering templates
ctx.getElement(customParent, ".item"); // Element
ctx.getElements("button"); // HTMLButtonElement[]For nullable results, use ctx.host.querySelector() directly.
Register arbitrary teardown logic to run on disconnect:
const raf = requestAnimationFrame(tick);
ctx.onCleanup(() => cancelAnimationFrame(raf));Cross-component communication via event-based context, similar to React's useContext. Import from nanotags/context (~0.4 KB).
import { createContext } from "nanotags/context";
const tabsCtx = createContext<TabsAPI>("tabs");Provider: call provide in the parent's setup:
define("x-tabs").setup((ctx) => {
const $active = atom(0);
tabsCtx.provide(ctx, {
$active,
register(tab: HTMLElement) { /* ... */ },
});
});Consumer: declare required contexts with withContexts. Setup is deferred until all contexts resolve:
define("x-tab")
.withContexts({ tabs: tabsCtx })
.setup((ctx) => {
ctx.contexts.tabs.register(ctx.host);
ctx.effect(ctx.contexts.tabs.$active, (index) => { /* ... */ });
});If a context never resolves (no provider ancestor), setup never runs. For dynamic/conditional access, use consume() directly:
tabsCtx.consume(ctx, (value) => { /* ... */ });How it works: provide() listens for context-request events on the host. consume() dispatches a context-request event that bubbles up. If the provider isn't upgraded yet, a document-level handler stores the pending request and resolves it when the provider calls provide().
Based on the Web Components Community Group Context Protocol.
Keyed reconciliation for dynamic content. Import from nanotags/render (~0.4 KB).
Reconcile a data array against DOM by key. Creates, updates, removes, and reorders 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>import { renderList } from "nanotags/render";
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: data (readonly array), key(item, index) (unique key), update(el, item) (called on create and when item ref changes).
Single-item rendering. Options are optional. Omit for static templates:
import { render } from "nanotags/render";
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.
Both render and renderList own the entire container: any child not part of the current cycle is removed.
Parents pass data down through props. Children notify parents via custom events (ctx.emit / ctx.on). When a child needs ongoing access to parent state, use the context protocol (nanotags/context). Unrelated components share Nano Stores atoms directly.
The primary channel. A parent sets attributes or properties on its children, and each child reacts via its own prop stores:
// Parent sets attribute, child's $mode atom updates automatically
childEl.setAttribute("mode", "dark");
// Or via property
childEl.mode = "dark";Standard DOM events. The child dispatches with ctx.emit, the parent listens with ctx.on:
// Child
ctx.emit("tab:select", { index: 2 });
// Parent
ctx.on(ctx.refs.tabs, "tab:select", (e) => {
console.log(e.detail.index); // 2
});Use the Context protocol. The parent exposes a value via provide(), descendants receive it via consume() or withContexts. This avoids tight coupling and works regardless of DOM depth.
Share a Nano Stores atom directly. Import the same store in both components and react via ctx.effect:
// shared store (plain module)
export const $theme = atom("light");
// component A
ctx.on(ctx.refs.toggle, "click", () => {
$theme.set($theme.get() === "light" ? "dark" : "light");
});
// component B
ctx.effect($theme, (theme) => {
ctx.host.dataset.theme = theme;
});You can also combine both approaches: provide a Nano Stores atom through the Context protocol so that siblings under the same parent share state without a global import:
// parent provides a shared store via context
const filterCtx = createContext<WritableAtom<string>>("filter");
define("x-filter-panel").setup((ctx) => {
const $filter = atom("");
filterCtx.provide(ctx, $filter);
});
// child A writes to the store
define("x-search-input")
.withRefs((r) => ({ input: r.one('input') }))
.withContexts({ filter: filterCtx })
.setup((ctx) => {
ctx.on(ctx.refs.input, "input", (e) => {
ctx.contexts.filter.set(e.currentTarget.value);
});
});
// sibling B reacts to changes
define("x-results-list")
.withContexts({ filter: filterCtx })
.setup((ctx) => {
ctx.effect(ctx.contexts.filter, (query) => {
// filter visible items
});
});Reusable functions that receive ctx and wire up behavior—effects, listeners, cleanup—without creating a new component. Think composable mixins:
export function attachRovingFocus(
ctx: SetupContext,
container: HTMLElement,
items: HTMLElement[],
) {
let active = 0;
items.forEach((item, i) => item.setAttribute("tabindex", i === 0 ? "0" : "-1"));
ctx.on(container, "keydown", (e) => {
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!dir) return;
e.preventDefault();
active = (active + dir + items.length) % items.length;
items.forEach((item, i) => item.setAttribute("tabindex", i === active ? "0" : "-1"));
items[active].focus();
});
}define("x-tabs")
.withRefs((r) => ({ tablist: r.one("div"), tabs: r.many("[role=tab]") }))
.setup((ctx) => {
attachRovingFocus(ctx, ctx.refs.tablist, ctx.refs.tabs);
});Since attachments receive ctx, listeners and effects are automatically cleaned up on disconnect.
TypedEvent<Target, Detail> is a type-only helper that narrows CustomEvent to a specific target and detail. Combine with HTMLElementEventMap augmentation for app-wide type-safe events:
import type { TypedEvent } from "nanotags";
type TabsChangedEvent = TypedEvent<InstanceType<typeof XTabs>, { index: number }>;
declare global {
interface HTMLElementEventMap {
"tabs:changed": TabsChangedEvent;
}
}
// Emitting:
ctx.emit("tabs:changed", { index: 2 });
// Listening:
ctx.on(tabsEl, "tabs:changed", (e) => {
e.target; // XTabs instance
e.detail; // { index: number }
});Register your element so that refs (r.one/r.many), ctx.getElement, ctx.getElements return properly typed instances:
declare global {
interface HTMLElementTagNameMap {
"x-my-el": InstanceType<typeof MyEl>;
}
}
const MyEl = define("x-my-el")
.withProps(/* ... */)
.setup(/* ... */);Why no Shadow DOM?
Shadow DOM brings encapsulation at the cost of complexity: styling piercing, slotting quirks, form participation hacks. nanotags targets server-rendered or static markup where global CSS is already the norm. Keeping elements in the light DOM means your existing styles, CSS frameworks, and dev tools work as expected.
How does it compare to Lit / Stencil / vanilla CE?
nanotags is intentionally minimal. It doesn't ship a template engine, virtual DOM, or lifecycle beyond connect/disconnect. If you need those, use Lit. If you want a thin reactivity layer over standard custom elements with TypeScript-first DX, nanotags is a good fit.
Does it work with SSR frameworks?
Yes. nanotags is designed for hydration: render markup on the server (Astro, PHP, Rails, static HTML), then hydrate on the client. Props are read from attributes, refs are resolved from existing DOM.
What happens when a context provider is missing?
When withContexts is used, setup is deferred until all declared contexts resolve. If a provider never appears, setup never runs and the element stays inert. Use consume() directly for contexts that may or may not be available.
MIT