Native Web Components have a reputation problem. The platform gives you Custom Elements, real DOM, and ordinary CSS for free, but the moment you try to build something nontrivial you find yourself hand-rolling attribute observers, coercing strings into numbers and booleans, tracking event listeners so you can remove them later, querying child elements by hand, and inventing your own reactivity. The standard is solid. The ergonomics are not.
nanotags is a small library that fills exactly that gap. It is a thin wrapper around native Custom Elements that adds reactivity through Nano Stores, a fully typed builder for declaring props and refs, and automatic cleanup of everything you wire up. The whole thing weighs under 2.5 KB, leans entirely on platform standards, and deliberately refuses to ship a template engine, a virtual DOM, or any lifecycle beyond connect and disconnect. The pitch is honest and narrow: keep the platform, remove the boilerplate.
One thing that makes nanotags interesting is what it leaves out. There is no Shadow DOM. Your markup stays in the regular light DOM, styled with the same global CSS you already use. That single decision shapes the whole design, and it is worth understanding before we look at the API.
A quick caveat first: nanotags is young. It sits at version 0.15.2, was first published in early 2026, and is maintained largely by one person. It has a small but real user base and an actively updated docs site, but it is still pre-1.0, so treat the API as something that may still shift. With that said, the ideas are sharp enough to be worth your attention today.
Why Skip the Shadow DOM
Shadow DOM offers encapsulation, and encapsulation sounds unambiguously good until you actually live with it. Styles do not pierce the boundary, so your design tokens and CSS framework stop applying unless you jump through hoops. Slotting has its own quirks. Form participation needs special hacks. Your dev tools and global stylesheets suddenly behave differently inside the shadow tree than everywhere else on the page.
nanotags makes a different bet. It assumes you are rendering HTML somewhere first, whether that is Astro, a server framework, or plain static files, and then hydrating it on the client. In that world, light DOM is a feature, not a compromise. Your existing CSS just works. Tailwind classes apply. The browser inspector shows you the real tree. Components become a way to add behavior and reactivity to markup that already exists, rather than a sealed box that renders everything itself.
This makes nanotags fundamentally hydration-first. You are not asking it to build your UI from scratch in JavaScript. You are asking it to bring static markup to life.
Getting It Installed
nanotags has one peer dependency, Nano Stores, which provides the reactive atoms it builds on. Install both together.
npm install nanotags nanostores
Or with yarn:
yarn add nanotags nanostores
The only thing nanotags itself depends on is @standard-schema/spec, a tiny specification package with no runtime weight. The reactivity comes from nanostores, and optional features like the context protocol and list rendering live behind separate import paths so they tree-shake away when you do not use them.
Defining Your First Element
Everything starts with define, a fluent builder that returns an immutable chain. Each step you add accumulates type information, so by the time you reach setup, your props and refs are fully inferred. For a trivial component you can skip the chain entirely and pass setup as the second argument.
import { define } from "nanotags";
const Logger = define("x-logger", (ctx) => {
console.log("connected:", ctx.host.tagName);
});
That registers a custom element named x-logger. Drop <x-logger></x-logger> anywhere in your markup and the setup function runs on connect. The ctx object is your toolbox: it gives you the host element, reactive prop stores, resolved refs, and a set of helpers that all clean up after themselves.
The more interesting components declare props and refs first.
import { define } from "nanotags";
const Counter = define("x-counter")
.withProps((p) => ({
count: p.number(),
label: p.string(),
}))
.withRefs((r) => ({
display: r.one("output"),
button: r.one("button"),
}))
.setup((ctx) => {
ctx.effect(ctx.props.$count, (count) => {
ctx.refs.display.textContent = String(count);
});
ctx.on(ctx.refs.button, "click", () => {
ctx.props.$count.set(ctx.props.$count.get() + 1);
});
});
Notice how the pieces fit. The matching HTML might be <x-counter count="0"><output></output><button>+1</button></x-counter>, and nanotags wires it up.
Props That Are Attributes, Stores, and Properties at Once
The withProps step is doing a surprising amount of work. Every prop you declare becomes three things simultaneously: an observed HTML attribute, a Nano Stores WritableAtom, and a typed getter and setter on the element instance. That means you can drive a component from markup, from reactive code, or from plain property assignment, and they all stay in sync.
.withProps((p) => ({
title: p.string(),
count: p.number(),
open: p.boolean(),
size: p.oneOf(["s", "m", "l"]),
}))
The built-in validators coerce the raw attribute strings into real typed values, so count arrives as a number and open as a boolean. If you want a prop to be nullable, pass null as the fallback: label: p.string(null) gives you string | null.
For complex data, the json validator accepts any Standard Schema library, which means you can validate with Valibot, Zod, or ArkType and get the inferred type back automatically.
import * as v from "valibot";
.withProps((p) => ({
items: p.json(
v.array(v.object({ id: v.number(), name: v.string() })),
[],
),
}))
JSON props hydrate once on connect, reading either a kebab-case attribute or a nested <script type="application/json"> tag, which is a clean way to pass structured data from the server into a component. And when a prop should live only as a property and never appear as an attribute, you can opt out: value: { schema: p.string(""), attribute: false }.
Refs Without the querySelector Tax
Reaching into your component's DOM is something you do constantly, and nanotags makes it typed and scoped. The withRefs step gives you r.one for a single element and r.many for a collection, with the tag name driving the return type.
.withRefs((r) => ({
trigger: r.one("button"),
items: r.many("li"),
custom: r.one(".my-trigger"),
typed: r.one<HTMLButtonElement>(".my-trigger"),
}))
If you pass a real tag name, the type is inferred for you. If you pass anything else, it is treated as a CSS selector, and you can supply the element type yourself with a generic. The important detail is that refs are scoped to this component and skip over nested custom elements by default, so a parent does not accidentally grab a child's internals. When you genuinely want to collect refs across a slotted hierarchy, you prefix the data-ref value with the owning element's tag name, and nanotags gathers them deeply.
Reactivity, Binding, and Cleanup
The setup context is where nanotags earns its keep, because nearly everything it hands you cleans itself up on disconnect. You never write a removeEventListener or an unsubscribe.
The core reactivity helper is effect, which subscribes to one or more Nano Stores atoms, runs your callback immediately, and tears down the subscription automatically when the element disconnects.
ctx.effect(ctx.props.$count, (count) => {
ctx.refs.display.textContent = String(count);
});
ctx.effect([storeA, storeB], (a, b) => {
// runs whenever either store changes
});
For form controls, bind sets up two-way binding between an atom and a DOM element, treating the store as the source of truth. It auto-detects checkboxes, inputs, textareas, selects, and custom elements that expose a .value.
ctx.bind($name, ctx.refs.nameInput);
ctx.bind($theme, el, { prop: "theme" });
ctx.bind($value, el, { prop: "value", event: "change" });
Events follow the same auto-cleanup pattern through on, with the event type inferred from the target, and emit dispatches custom events out to the rest of the page.
ctx.on(ctx.refs.trigger, "click", (e) => { /* ... */ });
ctx.on(document, "keydown", (e) => { /* ... */ });
ctx.emit("change", { value: 42 });
For anything else that needs teardown, like a requestAnimationFrame loop or a third-party widget, ctx.onCleanup lets you register a disposer that fires on disconnect. The throughline is that you describe what you want, and nanotags remembers to undo it.
Sharing State Between Components
Reactivity inside one element is useful, but real interfaces are made of components that need to talk. nanotags gives you two clean answers and avoids inventing a fourth state management paradigm.
For loose coupling, components simply share a Nano Stores atom. A parent sets a child's attribute or property and the child reacts through its prop store; a child notifies a parent by emitting a standard DOM custom event the parent listens for. That covers most cases with nothing but the platform.
For tighter coupling, like a tabs component coordinating with its tab children, there is a context protocol behind the nanotags/context entry point, weighing roughly 0.4 KB. A provider exposes an API, and consumers declare which contexts they need.
import { createContext } from "nanotags/context";
import { atom } from "nanostores";
const tabsCtx = createContext<{ $active: typeof atom }>("tabs");
define("x-tabs").setup((ctx) => {
const $active = atom(0);
tabsCtx.provide(ctx, { $active });
});
define("x-tab")
.withContexts({ tabs: tabsCtx })
.setup((ctx) => {
ctx.effect(ctx.contexts.tabs.$active, (index) => {
// react to the active tab
});
});
There is a thoughtful detail here. When you declare contexts with withContexts, the child's setup is deferred until every required context resolves. If a provider never shows up, the element simply stays inert instead of throwing. For genuinely optional dependencies, you call consume directly and handle the value yourself.
Rendering Lists Without a Virtual DOM
nanotags has no template engine, but dynamic lists still need to be reconciled efficiently, so there is a tiny nanotags/render entry point, again around 0.4 KB. It reconciles a data array against the DOM by key, creating, updating, removing, and reordering nodes from a <template> without recreating everything on each change.
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;
},
});
});
There is also a single-item render for swapping one chunk of content. Both helpers own their container entirely, so any child not present in the current cycle is removed, which keeps the mental model simple: the data is the source of truth and the DOM follows.
Where nanotags Fits
It helps to place nanotags against the alternatives. Lit is the heavyweight standard, with a tagged-template HTML engine, Shadow DOM by default, directives, and a large ecosystem. If you want declarative templating and real encapsulation, Lit is the answer, but it is several times larger and far broader in surface. Stencil is a compiler that outputs standards-based components with JSX and lazy loading, which is excellent for shipping a design system across frameworks but comes with a build step and heavier tooling. At the other extreme, plain Custom Elements give you zero dependencies and all the boilerplate we started this article complaining about.
nanotags sits squarely in the middle: vanilla Custom Elements with the tedium removed, plus nanostores reactivity and genuine TypeScript inference across props, refs, and contexts. If you already render HTML on the server, especially with Astro islands, and you want typed reactivity sprinkled onto that markup without buying into a framework or a sealed shadow tree, it is a remarkably good fit.
The honest summary is that nanotags is small, focused, and early. It is not trying to be your whole application framework, and that restraint is exactly its appeal. For light-DOM, hydration-first components built on standards you already know, it removes just enough boilerplate to make the platform pleasant, and it does so in less space than the icon you would use to represent it. If that is the kind of component model you have been wanting, nanotags is well worth a try.