stave.dev

Core Concepts

Templates, Shadow DOM encapsulation, the ShadowComponent base class, and how they fit together.

The Template System

Every ShadowComponent starts with an HTML <template> element. The template's id must match the custom element's tag name. Inside, you define the component's styles and markup:

<template id="my-component">
    <style>
        :host { display: block; }
        /* All styles here are scoped to this component */
    </style>
    <div class="wrapper">
        <slot name="title">Default Title</slot>
        <slot>Default content</slot>
    </div>
</template>

Templates are inert — the browser doesn't render their contents. When a component is created, ShadowComponent clones the template and attaches the clone to the element's shadow root.

Shadow DOM Encapsulation

The Shadow DOM creates a boundary around your component's internals. Styles defined inside the template cannot leak out to the rest of the page, and external styles cannot reach in. This encapsulation is enforced by the browser, not by convention.

Style Isolation

A .button class in your component won't conflict with a .button class on the page. You never need BEM, CSS Modules, or any other naming convention to avoid collisions.

There is one deliberate hole in the boundary: CSS custom properties (variables) cross the shadow boundary. This is how theming works — the page sets --accent-primary and every component can read it.

The ShadowComponent Base Class

ShadowComponent extends HTMLElement and handles the boilerplate that every Web Component needs:

What How
Shadow root Calls this.attachShadow({mode: "open"}) in the constructor
Template cloning Finds template#tag-name and appends its content to the shadow root
Observable properties Creates Object.defineProperty getter/setters for each entry in static observables
Attribute observation Wires static get observedAttributes() to return this.observables
Slot synchronization attributeChangedCallback creates/patches slot elements when attributes change
Error handling Structured handleError() with overridable onError() hook

Component File Structure

A ShadowComponent file bundles everything in one HTML file — template, styles, and script:

<template id="my-component">
    <style>
        :host {
            display: block;
            padding: 1rem;
        }
        .label {
            font-weight: bold;
            color: var(--accent-primary);
        }
    </style>
    <span class="label"><slot name="label">Label</slot></span>
    <div class="body"><slot>Content goes here</slot></div>
</template>

<script>
class MyComponent extends ShadowComponent {
    static observables = ["label"];

    connectedCallback() {
        super.connectedCallback();
        // Component is now in the DOM — safe to query, measure, listen
    }
}
customElements.define("my-component", MyComponent);
</script>
One File, One Component

Keeping template, styles, and script together means you only have one file to open when working on a component. No jumping between .js, .css, and .html files.

Registration

Custom elements must be registered with the browser before they can be used. There are two paths:

Explicit Registration

Components that need their own class (with methods, event handlers, or custom logic) register themselves:

class MyComponent extends ShadowComponent {
    static observables = ["title", "count"];
    // ...methods
}
customElements.define("my-component", MyComponent);

Auto-Registration

Templates with an id starting with shadow- are registered automatically on DOMContentLoaded. The framework creates a generic class with observables inferred from the template's <slot> elements:

// On DOMContentLoaded, for each template with id starting "shadow-":
let observableArray = Array.from(
    template.content.querySelectorAll("slot")
).map(slot => slot.getAttribute("name"));

let tempClass = class extends ShadowComponent {
    static observables = observableArray;
};

customElements.define(template.id, tempClass);
Guard Against Re-registration

If a component might be loaded more than once (e.g., included in multiple views), wrap the registration: if (!customElements.get("my-component")) { customElements.define(...); }

Complete Example

Here is a status badge component that puts all the pieces together:

<template id="status-badge">
    <style>
        :host {
            display: inline-flex;
            align-items: center;
            gap: 0.4rem;
            padding: 0.25rem 0.75rem;
            border-radius: 999px;
            font-size: 0.85rem;
            border: 1px solid var(--border-color);
            background: rgba(var(--surface-bg-rgb), 0.3);
        }
        :host([type="success"]) { border-color: #00cc66; color: #00ff88; }
        :host([type="error"])   { border-color: #cc0000; color: #ff4444; }
        :host([type="warning"]) { border-color: #ff9900; color: #ffcc00; }
        .dot {
            width: 6px;
            height: 6px;
            border-radius: 50%;
            background: currentColor;
        }
    </style>
    <span class="dot"></span>
    <slot name="label">Unknown</slot>
</template>

<script>
class StatusBadge extends ShadowComponent {
    static observables = ["label"];
}
customElements.define("status-badge", StatusBadge);
</script>

Usage:

<status-badge type="success">
    <span slot="label">Online</span>
</status-badge>

<status-badge type="error">
    <span slot="label">Disconnected</span>
</status-badge>