Documentation

Everything you need to build production-ready Web Components with Tan Compose — declarative syntax, Shadow DOM isolation, theming via CSS variables, and reactive state.

Introduction

Tan Compose is a lightweight, zero-dependency library for authoring native Web Components with a declarative API. You describe a component once with describe() and register it with build(); the library handles Shadow DOM setup, theming via CSS custom properties, inline styles, lifecycle hooks, attribute reactivity, custom events, and slots.

Installation

Install from JSR for Deno or Node:

# Deno deno add jsr:@ra9/tan-compose # Node / npm npx jsr add @ra9/tan-compose

CDN / Deno import

You can also import directly in Deno or the browser:

import { build, describe } from "https://deno.land/x/tan_compose/mod.ts";

Quick Start

Describe a component, then register it under a custom tag name. Custom tag names must be lowercase and contain a hyphen.

import { build, describe } from "@ra9/tan-compose"; const button = describe({ tag: "button", template: "Click me", styles: { padding: "10px 20px", backgroundColor: "#5b6cf0", color: "white", border: "none", borderRadius: "8px", cursor: "pointer", }, action: () => alert("Hello, Tan Compose!"), }); build("my-button", button);

Then use it in HTML like any native element:

<my-button></my-button>

API Reference

describe(options)

describe(options: DescribeOptions) => DescribeOptions

Validates and returns a component description. As of v0.2.0, this function throws on invalid options (missing tag, malformed observedAttributes, etc.).

build(tagName, description)

build(tagName: string, description: DescribeOptions) => string

Registers a custom element with the browser’s CustomElementRegistry and returns the registered tag name. The tagName must be lowercase and contain a hyphen (e.g. my-button); otherwise build() throws.

isComponentRegistered(tagName)

isComponentRegistered(tagName: string) => boolean

Returns true if a component with the given tag name has already been registered.

getRegisteredComponents()

getRegisteredComponents() => string[]

Returns an array of every tag name registered through build() in the current document.

DescribeOptions

The full shape accepted by describe():

FieldTypeDefaultDescription
tagstringHTML element used internally inside the Shadow DOM root (required).
themeRecord<string, string>{}CSS custom properties exposed as --name variables on the host.
stylesRecord<string, string>{}Inline styles applied to the component’s root element (camelCase keys).
classNamestring""Class name added to the root element.
attributesRecord<string, string>{}Static HTML attributes set on the root element.
templatestring | TemplateFn""Inner HTML rendered inside the root element. Function form re-evaluates on every render.
childrenDescribeOptions[][]Nested component descriptions composed inside the root.
action(event: Event) => voidClick handler attached to the root element.
eventsRecord<string, Handler>{}Delegated event handlers keyed by "<type> <selector>".
propsRecord<string, PropDef>{}Typed properties exposed on the host instance.
refsRecord<string, string>{}Map of name → CSS selector. Populated as host.refs.<name> after every render.
forListConfigKeyed list rendering: { items, key, render }.
if(ctx) => booleanSkip the subtree when the predicate returns false.
beforeMount(this: HTMLElement) => voidLifecycle hook called before the Shadow DOM is constructed.
afterMount(this: HTMLElement) => voidLifecycle hook called after the component is connected to the DOM.
afterRender(this: HTMLElement) => voidNew in 1.1.0. Lifecycle hook called after every render.
unmount(this: HTMLElement) => voidLifecycle hook called on disconnectedCallback.
observedAttributesstring[][]Explicit list of attributes that should trigger re-renders when changed.
formAssociatedbooleanfalseNew in 0.4.0. Opt into the Form-Associated Custom Elements API.

Component instance methods

Available as this inside lifecycle hooks and actions:

MethodReturnsDescription
setState(key, value)voidStores a value in component state. In 0.2.0 this triggers an automatic re-render.
getState(key)unknownReads the current value for a state key.
emitEvent(name, data)voidDispatches a CustomEvent declared in emit, with detail = data.
render()voidManually re-renders the Shadow DOM. Rarely needed since setState handles this.
refsRecord<string, Element | null>New in 0.4.0. Live references populated from the refs selector map.
internalsElementInternals?New in 0.4.0. Set when formAssociated: true.

Lifecycle

Tan Compose components follow a deterministic lifecycle. Hooks run in the order below:

  1. beforeMount — runs before the Shadow DOM is constructed. Use it to read attributes or seed state.
  2. DOM construction — the Shadow DOM, root element, theme variables, styles, and children are built.
  3. Mounted — the element is connected (connectedCallback).
  4. afterMount — runs after the component is in the DOM. Safe place to attach listeners or call setState.
  5. Updates — an attribute change in observedAttributes or a setState call triggers a re-render.
  6. afterRender — runs after every render (initial and subsequent). Use for imperative DOM work that needs to repeat.
  7. unmount — runs on disconnectedCallback. Use it to clean up timers and listeners.
Avoid mutating the Shadow DOM directly inside afterMount — prefer setState so subsequent updates stay consistent.

Reactivity

There are two ways to update a Tan Compose component after it has mounted:

  • State — call this.setState(key, value). As of 0.2.0, every setState call triggers an automatic re-render of the Shadow DOM.
  • Attributes — declare them explicitly in observedAttributes. Only attributes listed there will trigger a re-render when changed via setAttribute() or directly in markup.
const counter = describe({ tag: "div", observedAttributes: ["count"], template: "0", afterMount() { this.setState("count", Number(this.getAttribute("count") ?? 0)); }, }); build("my-counter", counter);

Typed properties (0.3+)

The props field declares typed properties on the host element. Setting one (el.rows = […]) triggers a re-render when the value differs. Initial values come from the matching attribute (string-coerced) when present, otherwise from default.

build("user-card", describe({ props: { name: { type: "string", default: "Anonymous" }, age: { type: "number", default: 0 }, open: { type: "boolean", default: false, reflect: true }, tags: { type: "json", default: [] }, }, template: ({ props }) => `<h3>${props.name}, ${props.age}</h3> ${props.open ? "<span>open</span>" : ""}`, }));

PropDef

FieldTypeDescription
type"string" | "number" | "boolean" | "json"How attribute strings coerce into the runtime value.
defaultunknownUsed when the attribute is absent and no value has been set.
reflectbooleanWhen true, setting the property mirrors the value to the matching attribute. Ignored for json props.
Prop names are also added to the underlying observedAttributes list, so an external setAttribute still updates the property (with type coercion).

Function templates (0.3+)

template may be a function that receives the render context and returns an HTML string. It runs on every render, so any expression in scope (props, state) is implicitly reactive.

describe({ props: { greeting: { type: "string", default: "hi" } }, template: ({ props, state }) => `<p>${props.greeting} #${state.tick ?? 0}</p>`, });

ComponentCtx

FieldTypeDescription
propsReadonly<Record<string, unknown>>Snapshot of current property values.
stateReadonly<Record<string, unknown>>Snapshot of current state values.
hostHTMLElementThe custom element instance.
refsRecord<string, Element | null>Live references from the refs selector map.
setState(key, value) => voidSets state and re-renders if the value changed.
getState<T>(key) => T | undefinedReads a state value.
emit(name, detail?) => voidDispatches a bubbling, composed CustomEvent from the host.
Template strings are inserted via innerHTML. Never interpolate untrusted input. Use the DOM API in afterMount if you need to insert user content as text.

Event delegation (0.3+)

The events field maps "<event-type> <css-selector>" keys to handlers. One listener per event type is attached at the shadow root; matches are resolved via composedPath(). Listeners are torn down on disconnect.

describe({ template: ` <button class="bump">+</button> <button class="reset">reset</button> `, events: { "click .bump": (_e, ctx) => ctx.setState("count", (ctx.state.count ?? 0) + 1), "click .reset": (_e, ctx) => ctx.setState("count", 0), }, });

Handler signature is (event: Event, ctx: ComponentCtx) => void. Selector-less keys (e.g. "submit") match events that target the host directly.

Keyed list rendering (0.3+)

A child describe can take for: { items, key, render }. The renderer keeps a per-block cache keyed by key(item) and reuses DOM nodes between renders when item identity is unchanged. Stale items have their cleanups run.

describe({ tag: "tbody", for: { items: ({ props }) => props.rows, key: (row) => row.id, render: (row) => describe({ tag: "tr", template: `<td>${row.name}</td><td>${row.email}</td>`, }), }, });

ListConfig

FieldTypeDescription
items(ctx) => readonly T[]Returns the current items. Recomputed on every parent render.
key(item, index) => string | numberStable identity used for DOM-node reuse.
render(item, index, ctx) => DescribeOptionsReturns the description for one item.
The cache reuses a node only when Object.is(prevItem, newItem). If you mutate items in place the renderer can't tell that the data changed; replace the array (and any changed entries) with new references instead.

Conditional rendering (0.3+)

A child describe can take if: (ctx) => boolean. When the predicate returns false, the subtree is omitted from the rendered output (no DOM created at all).

describe({ tag: "div", className: "empty", template: "No results", if: ({ props }) => props.rows.length === 0, });

Putting it together: a real datatable

The features above compose into a paginated, filterable, sortable datatable. Open the examples page for the live version; here is the abridged source:

build("data-table", describe({ props: { rows: { type: "json", default: [] }, columns: { type: "json", default: [] }, pageSize: { type: "number", default: 10 }, }, template: ({ state, props }) => ` <input class="filter" placeholder="Search..." value="${state.q ?? ""}" /> <table> <thead><tr> ${(props.columns ?? []).map((c) => `<th data-col="${c.key}">${c.label}</th>`).join("")} </tr></thead> </table> `, events: { "input .filter": (e, ctx) => { ctx.setState("q", e.target.value); ctx.setState("page", 0); }, }, children: [ describe({ tag: "tbody", for: { items: ({ state, props }) => visibleRows(state, props), key: (row) => row.id, render: (row, _i, { props }) => describe({ tag: "tr", template: (props.columns ?? []).map((c) => `<td>${row[c.key]}</td>`).join(""), }), }, }), ], }));

Because for is keyed by row.id, typing in the search box re-uses the same row nodes — the renderer just updates which slice is visible. No virtual DOM, no diff library: just the platform plus a couple hundred lines of glue.

Refs (0.4+)

The refs field is a name → CSS-selector map. After every render, the matching shadow-root elements are exposed as host.refs.<name> and ctx.refs.<name>. Selectors that match nothing yield null. Refs are re-queried on every render so they always point at the current DOM.

build("search-box", describe({ refs: { input: ".q", clear: ".x" }, template: `<input class="q" /><button class="x">clear</button>`, events: { "click .x": (_e, ctx) => { // ctx.refs.input is the live <input> (ctx.refs.input as HTMLInputElement).value = ""; (ctx.refs.input as HTMLInputElement).focus(); }, }, })); Use refs whenever you find yourself calling this.shadowRoot.querySelector(...) in afterMount. Refs are cheaper, declarative, and stay in sync with re-renders automatically.

Form-Associated Custom Elements (0.4+)

Set formAssociated: true and the host calls attachInternals() automatically. If you declare a value prop, its setter syncs to internals.setFormValue so the host participates in form submissions and the validity API.

build("tc-input", describe({ formAssociated: true, props: { value: { type: "string", default: "" }, placeholder: { type: "string", default: "" }, }, template: ({ props }) => `<input class="q" placeholder="${props.placeholder}" value="${props.value}" />`, refs: { input: ".q" }, events: { "input .q": (e, ctx) => { ctx.host.value = (e.target as HTMLInputElement).value; }, }, formResetCallback() { // fired when the owning <form> is reset (this as unknown as { value: string }).value = ""; }, }));

The internals are exposed at host.internals so you can call setValidity(), setFormValue() directly, or check internals.form.

Lifecycle hooks

HookFires on
formAssociatedCallback(form)Element associated with a form (or moved between forms).
formDisabledCallback(disabled)Disabled state changes (e.g. via parent fieldset).
formResetCallback()Owning form is reset.
formStateRestoreCallback(state, mode)State restored on history navigation or autocomplete.

Adopted stylesheets (0.4+)

theme and styles are compiled into CSSStyleSheet objects once per registered tag and applied to each instance via shadowRoot.adoptedStyleSheets. 100 instances of the same tag share 1–2 sheets instead of 100 inline <style> elements.

Falls back to per-instance <style> tags when constructable stylesheets aren't available. The change is invisible to user code — same CSS, same theming, less work for the renderer.

A long list using for: { ... } with rows that are themselves custom elements. Before 0.4, every row got its own copy of the stylesheet; now they share.

Migration: 0.1.x → 0.2.0

Version 0.2.0 is a minor release with breaking changes. Review each item below before upgrading:

  • Attribute reactivity is now opt-in. Add a string array to observedAttributes to receive re-renders. The old behavior of inferring observed attributes from the attributes keys has been removed.
  • setState auto-renders. You no longer need to call render() manually after updating state.
  • describe() validates input. Invalid options (missing tag, non-array observedAttributes, non-function lifecycle hooks) now throw at description time.
  • build() validates the tag name. The first argument must be lowercase and contain a hyphen. Names like MyButton or button are rejected.
  • New unmount lifecycle hook. Pair it with afterMount for cleanup on disconnectedCallback.

Migration: 0.2.x → 0.3.0

Version 0.3.0 is additive — existing 0.2.x code continues to work. To opt in to the new features:

  • Pass complex data via properties, not attributes. Add a props map and assign on the instance: el.rows = […]. JSON props are parsed from attributes when present and serialized only on read.
  • Use function templates for derived text. Replace template strings that hard-code values with template: ({ props, state }) => "..." so changes flow through automatically.
  • Replace manual afterMount wiring with events. Selector-keyed handlers attach once and clean up on disconnect, no per-render addEventListener calls.
  • Use for for any list of three or more items. It's the only path to keyed DOM reuse, which matters the moment you have a filter input over a list.

Migration: 0.3.x → 0.4.0

Additive release. Existing 0.3.x code continues to work. Opt in to the new features as you need them:

  • Replace this.shadowRoot.querySelector with refs. Declare the selectors once, get fresh references on every render.
  • Add formAssociated: true + a value prop to anything that represents a form field. It will start submitting with <form> automatically.
  • No code change is required for adopted stylesheets — the renderer picks the optimal path automatically wherever CSSStyleSheet.replaceSync is supported.

Migration: 0.4.x → 1.0.0

The 1.0 release is mostly additive. One subtle change to be aware of:

  • for: now wraps in an element. If you had a child describe with ONLY for: (no other fields) and relied on items appearing ungrouped, they're now wrapped in a <div> by default. Set tag to override (e.g. tag: "tbody" when the parent is a <table>).
  • children + for: can coexist. Code that caught the old "cannot set both" TypeError can drop the special case.