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:
CDN / Deno import
You can also import directly in Deno or the browser:
Quick Start
Describe a component, then register it under a custom tag name. Custom tag names must be lowercase and contain a hyphen.
Then use it in HTML like any native element:
API Reference
describe(options)
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)
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)
Returns true if a component with the given tag name has already been registered.
getRegisteredComponents()
Returns an array of every tag name registered through
build() in the current document.
DescribeOptions
The full shape accepted by describe():
| Field | Type | Default | Description |
|---|---|---|---|
| tag | string | — | HTML element used internally inside the Shadow DOM root (required). |
| theme | Record<string, string> | {} | CSS custom properties exposed as --name variables on the host. |
| styles | Record<string, string> | {} | Inline styles applied to the component’s root element (camelCase keys). |
| className | string | "" | Class name added to the root element. |
| attributes | Record<string, string> | {} | Static HTML attributes set on the root element. |
| template | string | TemplateFn | "" | Inner HTML rendered inside the root element. Function form re-evaluates on every render. |
| children | DescribeOptions[] | [] | Nested component descriptions composed inside the root. |
| action | (event: Event) => void | — | Click handler attached to the root element. |
| events | Record<string, Handler> | {} | Delegated event handlers keyed by "<type> <selector>". |
| props | Record<string, PropDef> | {} | Typed properties exposed on the host instance. |
| refs | Record<string, string> | {} | Map of name → CSS selector. Populated as host.refs.<name> after every render. |
| for | ListConfig | — | Keyed list rendering: { items, key, render }. |
| if | (ctx) => boolean | — | Skip the subtree when the predicate returns false. |
| beforeMount | (this: HTMLElement) => void | — | Lifecycle hook called before the Shadow DOM is constructed. |
| afterMount | (this: HTMLElement) => void | — | Lifecycle hook called after the component is connected to the DOM. |
| afterRender | (this: HTMLElement) => void | — | New in 1.1.0. Lifecycle hook called after every render. |
| unmount | (this: HTMLElement) => void | — | Lifecycle hook called on disconnectedCallback. |
| observedAttributes | string[] | [] | Explicit list of attributes that should trigger re-renders when changed. |
| formAssociated | boolean | false | New in 0.4.0. Opt into the Form-Associated Custom Elements API. |
Component instance methods
Available as this inside lifecycle hooks and actions:
| Method | Returns | Description |
|---|---|---|
| setState(key, value) | void | Stores a value in component state. In 0.2.0 this triggers an automatic re-render. |
| getState(key) | unknown | Reads the current value for a state key. |
| emitEvent(name, data) | void | Dispatches a CustomEvent declared in emit, with detail = data. |
| render() | void | Manually re-renders the Shadow DOM. Rarely needed since setState handles this. |
| refs | Record<string, Element | null> | New in 0.4.0. Live references populated from the refs selector map. |
| internals | ElementInternals? | New in 0.4.0. Set when formAssociated: true. |
Lifecycle
Tan Compose components follow a deterministic lifecycle. Hooks run in the order below:
beforeMount— runs before the Shadow DOM is constructed. Use it to read attributes or seed state.- DOM construction — the Shadow DOM, root element, theme variables, styles, and children are built.
- Mounted — the element is connected (
connectedCallback). afterMount— runs after the component is in the DOM. Safe place to attach listeners or callsetState.- Updates — an attribute change in
observedAttributesor asetStatecall triggers a re-render. afterRender— runs after every render (initial and subsequent). Use for imperative DOM work that needs to repeat.unmount— runs ondisconnectedCallback. Use it to clean up timers and listeners.
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, everysetStatecall 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 viasetAttribute()or directly in markup.
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.
PropDef
| Field | Type | Description |
|---|---|---|
| type | "string" | "number" | "boolean" | "json" | How attribute strings coerce into the runtime value. |
| default | unknown | Used when the attribute is absent and no value has been set. |
| reflect | boolean | When true, setting the property mirrors the value to the matching attribute. Ignored for json props. |
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.
ComponentCtx
| Field | Type | Description |
|---|---|---|
| props | Readonly<Record<string, unknown>> | Snapshot of current property values. |
| state | Readonly<Record<string, unknown>> | Snapshot of current state values. |
| host | HTMLElement | The custom element instance. |
| refs | Record<string, Element | null> | Live references from the refs selector map. |
| setState | (key, value) => void | Sets state and re-renders if the value changed. |
| getState | <T>(key) => T | undefined | Reads a state value. |
| emit | (name, detail?) => void | Dispatches a bubbling, composed CustomEvent from the host. |
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.
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.
ListConfig
| Field | Type | Description |
|---|---|---|
| items | (ctx) => readonly T[] | Returns the current items. Recomputed on every parent render. |
| key | (item, index) => string | number | Stable identity used for DOM-node reuse. |
| render | (item, index, ctx) => DescribeOptions | Returns the description for one item. |
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).
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:
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.
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.
The internals are exposed at host.internals so you can
call setValidity(), setFormValue() directly,
or check internals.form.
Lifecycle hooks
| Hook | Fires 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.
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
observedAttributesto receive re-renders. The old behavior of inferring observed attributes from theattributeskeys has been removed. setStateauto-renders. You no longer need to callrender()manually after updating state.describe()validates input. Invalid options (missingtag, non-arrayobservedAttributes, 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 likeMyButtonorbuttonare rejected.- New
unmountlifecycle hook. Pair it withafterMountfor cleanup ondisconnectedCallback.
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
propsmap 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
afterMountwiring withevents. Selector-keyed handlers attach once and clean up on disconnect, no per-renderaddEventListenercalls. - Use
forfor 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.querySelectorwithrefs. Declare the selectors once, get fresh references on every render. - Add
formAssociated: true+ avalueprop 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.replaceSyncis 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 ONLYfor:(no other fields) and relied on items appearing ungrouped, they're now wrapped in a<div>by default. Settagto 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.