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.
import { define } from "nanotags";
define("copy-btn")
.withRefs((r) => ({ code: r.one("code") }))
.setup((ctx) => {
ctx.on(ctx.host, "click", async () => {
await navigator.clipboard.writeText(ctx.refs.code.textContent);
ctx.host.classList.add('active');
setTimeout(() => ctx.host.classList.remove('active'), 1000);
});
});
Why nanotags?
- 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
Installation
pnpm add nanotags nanostores npm install nanotags nanostores yarn add nanotags nanostores bun add nanotags nanostores nanostores is a peer dependency.
CDN
No build step needed. Add an import map to your HTML:
<script type="importmap">
{
"imports": {
"nanotags": "https://esm.sh/nanotags@0.13.1",
"nanostores": "https://esm.sh/nanostores@1"
}
}
</script> <script type="importmap">
{
"imports": {
"nanotags": "https://cdn.jsdelivr.net/npm/nanotags@0.13.1/+esm",
"nanostores": "https://cdn.jsdelivr.net/npm/nanostores@1.2/+esm"
}
}
</script> Then use bare specifiers as usual:
<script type="module">
import { define } from "nanotags";
import { atom } from "nanostores";
</script>
Sub-entry points (nanotags/render, nanotags/context) follow the same URL pattern.
Quick Start
Start with markup: nanotags hydrates existing DOM rather than rendering from scratch:
<x-counter count="0">
<span data-ref="display">0</span>
<button data-ref="increment">+1</button>
</x-counter>
Then define the component:
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);
});
});
What happens:
define("x-counter"): starts the builder chain, names the custom element.withProps: declares acountattribute parsed as a number, exposed asctx.props.$countatom.withRefs: declares typed refs resolved via[data-ref="name"]selectors.setup: wires event listeners and reactive effects; everything is auto-cleaned on disconnect
Lifecycle
nanotags builds on the standard Custom Elements lifecycle with a thin reactive layer on top.
1. Constructor
Reactive prop stores are created and getter/setter descriptors are defined on the element instance. Attribute-backed props read their initial value from the DOM; JSON and property-only props start as undefined.
The element is usable as a JS object at this point, but it is not connected to the DOM and setup() has not run.
2. connectedCallback
All props are hydrated: each prop’s get function is called, the raw value is parsed through the schema, and the corresponding atom is set. Then setup() runs.
If withContexts() was used, setup is deferred until all declared contexts resolve. See Context API for details.
3. attributeChangedCallback
Fires when an observed attribute changes. The new value is validated through the prop’s schema and pushed to the corresponding atom. Only attribute-backed props trigger this; JSON and property-only props are not observed.
4. disconnectedCallback
All registered cleanups run: event listeners are removed, store subscriptions are cancelled, and any onCleanup() callbacks execute. The cleanup list is then cleared.
Reconnection
Re-connecting a previously disconnected component runs setup() again with a fresh cleanup scope. Props that were set programmatically (via the property setter) retain their values; attribute-backed props that were never set programmatically re-read from the DOM.
This means:
- All props are re-hydrated
- Effects and listeners are re-registered
- Refs are re-resolved from the current DOM
- Mixin members are re-assigned
Cleanup guarantees
All of these are auto-cleaned on disconnect:
- Event listeners registered via
ctx.on() - Store subscriptions from
ctx.effect() - Bindings from
ctx.bind() - Custom teardown from
ctx.onCleanup()
If a cleanup function throws, the remaining cleanups still execute. The first error is re-thrown after all cleanups complete.
FAQ
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.