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.
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>
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);
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>