Tan Compose is a tiny library for declaratively defining reusable Web
Components. One describe() call captures a component's tag, theme, styles,
template, lifecycle hooks, and reactive state; one build() call registers it
as a real custom element. No JSX, no compiler, no framework runtime — Shadow
DOM, lifecycle, and reactivity are wired up for you on top of the platform.
Status: stable at 1.0. The API is frozen; further changes will be additive. Pair with
@ra9/tan-compose-kitfor 19 ready-made components and 4 theme presets (light, dark, Bootstrap, Tailwind) on top of this engine.
- Declarative Component Definition: Easily describe web components using a JSON-like structure
- Component Registry: Prevents duplicate registrations and allows component reusability
- Theming and Styling: Apply styles through CSS variables and inline styles with proper Shadow DOM encapsulation
- Shadow DOM Encapsulation: Prevent style leakage and ensure component isolation
- Event Emitters: Communicate between child and parent components via custom events
- Typed Properties: Declarative
string/number/boolean/jsonprops with reactive setters and optional attribute reflection - Function Templates: Templates can be functions
(ctx) => stringfor reactive text interpolation - Event Delegation: Map-based delegated event handlers
(
"click .selector": handler) attached once at the shadow-container level - Keyed List Rendering:
for: { items, key, render }with DOM-node reuse across renders for performant lists - Conditional Rendering:
if: (ctx) => booleanto omit subtrees from output - Reactive Attributes: Opt-in via
observedAttributes; changes trigger re-render - State Management: Built-in
setState/getState(setState triggers re-render when the value changes) - Lifecycle Hooks:
beforeMount,afterMount, andunmount - Memory Leak Prevention: Automatic cleanup of event listeners and resources
- Recursive Component Building: Nest and compose components in a declarative way
- Template Support: Use HTML templates for component content
With Deno (JSR):
deno add jsr:@ra9/tan-composeWith npm/Node bundlers (via JSR):
npx jsr add @ra9/tan-composeOr import directly from a URL with Deno:
import { build, describe } from "https://deno.land/x/tan_compose/mod.ts";Create a simple button component using Tan Compose.
import { build, describe } from "tan-compose";
const formBtn = describe({
tag: "button",
action: (event) => console.log("Button clicked!", event),
className: "my-button",
styles: { margin: "10px", padding: "5px" },
});
build("tan-btn", formBtn);Use it in HTML:
<tan-btn>Click Here</tan-btn>Create components with HTML templates:
const card = describe({
tag: "div",
className: "card",
template: `
<h2>Card Title</h2>
<p>This is a card component with template support.</p>
`,
styles: {
border: "1px solid #ddd",
borderRadius: "8px",
padding: "20px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
},
});
build("tan-card", card);<tan-card></tan-card>Build complex UIs by nesting components:
import { build, describe } from "tan-compose";
const formComponent = describe({
tag: "form",
theme: {
primaryColor: "#007bff",
secondaryColor: "#6c757d",
spacing: "10px",
},
styles: {
padding: "20px",
border: "1px solid #e0e0e0",
borderRadius: "8px",
maxWidth: "400px",
backgroundColor: "#fff",
},
children: [
describe({
tag: "div",
className: "form-group",
styles: { marginBottom: "var(--spacing)" },
children: [
describe({
tag: "label",
template: "Full Name:",
styles: { display: "block", marginBottom: "5px", fontWeight: "bold" },
}),
describe({
tag: "input",
attributes: {
name: "fullName",
placeholder: "Enter your full name",
type: "text",
},
styles: {
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
},
}),
],
}),
describe({
tag: "div",
className: "form-group",
styles: { marginBottom: "var(--spacing)" },
children: [
describe({
tag: "label",
template: "Email:",
styles: { display: "block", marginBottom: "5px", fontWeight: "bold" },
}),
describe({
tag: "input",
attributes: {
name: "email",
type: "email",
placeholder: "Enter your email",
},
styles: {
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
},
}),
],
}),
describe({
tag: "button",
attributes: { type: "submit" },
template: "Submit",
action: (event) => {
event.preventDefault();
console.log("Form submitted!");
},
styles: {
backgroundColor: "var(--primaryColor)",
color: "white",
padding: "10px 20px",
border: "none",
borderRadius: "4px",
cursor: "pointer",
},
}),
],
});
build("tan-form", formComponent);<tan-form></tan-form>Use beforeMount and afterMount hooks:
const component = describe({
tag: "div",
template: "<p>Component with lifecycle hooks</p>",
beforeMount: () => {
console.log("Component is about to mount");
},
afterMount: () => {
console.log("Component has mounted");
},
});
build("lifecycle-component", component);Create components that emit custom events:
const counterBtn = describe({
tag: "button",
template: "Increment Counter",
emit: [
{
name: "counterChanged",
handler: (e) => {
console.log("Counter value:", e.detail.count);
},
},
],
action: function (event) {
// Emit custom event
this.emitEvent("counterChanged", { count: Math.random() });
},
});
build("counter-btn", counterBtn);<counter-btn></counter-btn>Apply consistent theming across components:
const themedButton = describe({
tag: "button",
theme: {
primaryColor: "#ff6b6b",
hoverColor: "#ff5252",
textColor: "#ffffff",
},
template: "Themed Button",
styles: {
backgroundColor: "var(--primaryColor)",
color: "var(--textColor)",
padding: "12px 24px",
border: "none",
borderRadius: "6px",
cursor: "pointer",
transition: "background-color 0.3s",
},
});
build("themed-btn", themedButton);<themed-btn></themed-btn>Declare reactive properties on the instance. Setting one triggers a re-render:
import { build, describe } from "@ra9/tan-compose";
build(
"user-card",
describe({
props: {
name: { type: "string", default: "Anonymous" },
age: { type: "number", default: 0 },
isAdmin: { type: "boolean", default: false, reflect: true },
tags: { type: "json", default: [] },
},
template: ({ props }) => `
<h3>${props.name}, ${props.age}</h3>
${props.isAdmin ? '<span class="admin">admin</span>' : ""}
${(props.tags ?? []).map((t) => `<span>${t}</span>`).join(" ")}
`,
}),
);<user-card name="Carlos" age="29" tags='["dev","ops"]'></user-card>
<script type="module">
document.querySelector("user-card").tags = ["dev", "ops", "design"];
</script>template can be a function that receives the render context. Combine with
events to keep handlers declarative:
build(
"click-counter",
describe({
template: ({ state }) => `
<p>Count: ${state.count ?? 0}</p>
<button class="bump">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),
},
}),
);Render keyed lists with DOM-node reuse and short-circuit subtrees with if:
build(
"todo-list",
describe({
props: {
items: { type: "json", default: [] },
hideDone: { type: "boolean", default: false },
},
children: [
describe({
tag: "p",
template: "All done!",
if: ({ props }) => props.hideDone && props.items.every((i) => i.done),
}),
describe({
tag: "ul",
for: {
items: ({ props }) =>
props.hideDone ? props.items.filter((i) => !i.done) : props.items,
key: (item) => item.id,
render: (item) =>
describe({
tag: "li",
template: `${item.done ? "✔" : "○"} ${item.text}`,
}),
},
}),
],
}),
);Declare which attributes should trigger re-renders via observedAttributes:
const dynamicText = describe({
tag: "div",
observedAttributes: ["data-text"],
attributes: { "data-text": "Initial text" },
template: "Check the DOM on attribute change",
});
build("dynamic-text", dynamicText);Breaking change in 0.2.0: Reactivity is now opt-in. In 0.1.x, every key of
attributeswas implicitly observed; you must now list them explicitly inobservedAttributes.
<dynamic-text data-text="Initial"></dynamic-text>
<script>
// Change attribute dynamically
const el = document.querySelector("dynamic-text");
setTimeout(() => {
el.setAttribute("data-text", "Updated text");
}, 2000);
</script>Use built-in state management:
const statefulComponent = describe({
tag: "div",
template: "<button>Click to update state</button>",
afterMount: function () {
this.setState("count", 0);
this.querySelector("button").addEventListener("click", () => {
const count = this.getState("count") + 1;
this.setState("count", count);
console.log("Current count:", count);
});
},
});
build("stateful-component", statefulComponent);Registers a new custom element with the given tag name.
Parameters:
tagName: The custom element tag name (must contain a hyphen)description: Component description object
Creates a component description object.
Options:
tag?: string- HTML tag to create (default: 'div')theme?: Record<string, string>- CSS variables for themingstyles?: Record<string, string>- Inline stylesclassName?: string- CSS class namesattributes?: Record<string, string>- HTML attributestemplate?: string- HTML template contentchildren?: DescribeOptions[]- Array of child componentsaction?: (event: Event) => void- Click event handleremit?: EventEmitter[]- Custom event emittersobservedAttributes?: string[]- Attribute names that trigger re-rendersbeforeMount?: () => void- Hook called before first renderafterMount?: () => void- Hook called after the element is connectedunmount?: () => void- Hook called when the element is disconnected
Custom components have these methods available:
emitEvent(eventName: string, data: any)- Emit a custom eventsetState(key: string, value: any)- Set a state value (triggers re-render if the value changed)getState<T>(key: string): T | undefined- Get a state valuerender()- Manually trigger a re-render
isComponentRegistered(tagName: string): boolean- Check if component is registeredgetRegisteredComponents(): string[]- Get all registered component names
The library automatically prevents duplicate component registrations:
build("my-component", describe({ tag: "div" }));
build("my-component", describe({ tag: "div" })); // Warning logged, skips re-registrationAll event listeners are automatically cleaned up when components are removed from the DOM, preventing memory leaks.
See the /examples folder for complete working examples:
- Basic Button Component
- Form with Validation
- Themed Dashboard
- Interactive Card Component
- State Management Example
Works in all modern browsers that support:
- Custom Elements v1
- Shadow DOM v1
- ES6 Classes
MIT
Contributions are welcome! Please feel free to submit a Pull Request.