A thin Web Components wrapper powered by [Nano Stores](https://github.com/nanostores/nanostores) 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**.

```typescript
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](https://github.com/nanostores/nanostores) 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/render` and `nanotags/context` are separate entry points
- **Standard Schema**: built-in validators plus any [Standard Schema](https://github.com/standard-schema/standard-schema)-compatible library (Valibot, Zod, ArkType)
- **Hydration-first**: built for statically rendered markup. Pair with [Astro](https://astro.build/), server-rendered HTML, or any static-first setup to hydrate lightweight interactive islands

## Installation

[`nanostores`](https://github.com/nanostores/nanostores) is a peer dependency.

**CDN**

No build step needed. Add an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) to your HTML:

Then use bare specifiers as usual:

```html
<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:

```html
<x-counter count="0">
  <span data-ref="display">0</span>
  <button data-ref="increment">+1</button>
</x-counter>
```

Then define the component:

```typescript
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:

1. **[`define("x-counter")`](api#define)**: starts the builder chain, names the custom element
2. **[`.withProps`](api#withprops)**: declares a `count` attribute parsed as a number, exposed as `ctx.props.$count` atom
3. **[`.withRefs`](api#withrefs)**: declares typed refs resolved via `[data-ref="name"]` selectors
4. **[`.setup`](api#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()`](api#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()`](api#setup) runs.

If [`withContexts()`](api#withcontexts) was used, setup is deferred until all declared contexts resolve. See [Context API](cookbook#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()`](api#oncleanup) callbacks execute. The cleanup list is then cleared.

**Reconnection**

Re-connecting a previously disconnected component runs [`setup()`](api#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()`](api#on)
- Store subscriptions from [`ctx.effect()`](api#effect)
- Bindings from [`ctx.bind()`](api#bind)
- Custom teardown from [`ctx.onCleanup()`](api#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()`](api#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()`](api#contextconsume) directly for contexts that may or may not be available.
