1. Interaction
  2. APIs
  3. Components
  4. Web Components

An APIs series

Web Components

Every framework you have used ships a component model. So does the browser. It has no build step, no runtime to download, no migration when the ecosystem moves on, and it has been in every shipping browser for years. This series is about that model โ€” what it actually gives you, where it is genuinely better than the framework you know, and where it is genuinely worse.

Every step of the series on one page. Use the step pages to work through it a step at a time.

Step 01

Semantic HTML foundation

This step uses custom elements as semantic HTML markers before any JavaScript exists. Pages written this way work with just HTML and CSS, following progressive enhancement principles โ€” combine that with hover effects, invokers (commandfor), dialogs, popovers, and view transitions, and you can get surprisingly far before writing a script. But starting here also means the move to registered web components, or to a static site generator, is seamless rather than a rewrite.

Custom Elements as Semantic Markers

Custom elements can be used to add semantic meaning to your HTML structure even without JavaScript. This approach replaces "div soup" with meaningful, descriptive element names.

  • โŒ Old Way: Div Soup

    <div class="card"> <div class="card-title">Title</div> <div class="card-price">$29.99</div> </div>
  • โœ… New Way: Semantic Custom Elements

    <product-card> <product-title>Title</product-title> <price-display>$29.99</price-display> </product-card>

Demo 1: Product Grid Component

A responsive grid displaying product cards with semantic custom elements. Notice how the element names clearly describe what they represent.

Custom Elements Used

  • <product-grid> - Container for product cards
  • <product-card> - Individual product wrapper
  • <card-image> - Product image container
  • <card-content> - Content wrapper
  • <product-title> - Product name
  • <product-description> - Product details
  • <price-display> - Price information
  • <product-rating> - Star rating display
  • <card-actions> - Action buttons container

HTML Structure

<product-card role="gridcell" data-price="29.99" data-category="electronics"> <card-image> <img src="https://placehold.co/200x150" alt="Smartphone"> </card-image> <card-content> <product-title>Smartphone</product-title> <product-description> The latest model features advanced technology. </product-description> <price-display currency="USD">$29.99</price-display> <product-rating stars="4.5">โ˜…โ˜…โ˜…โ˜…โ˜…</product-rating> </card-content> <card-actions> <add-to-cart-button role="button" tabindex="0"> Add to Cart </add-to-cart-button> <wishlist-button role="button" tabindex="0"> โ™ก Save </wishlist-button> </card-actions> </product-card>

Live Demo

CSS Styling

/* Notice with the custom elements and nesting just how simple our CSS gets! Literal values here, the same ones the demo file uses โ€” see the note under "CSS Architecture Tips" below for why. */ product-card { display: block; background: #ffffff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; &:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } card-content { display: block; padding: 1.5rem; } product-title { display: block; font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: #1f2937; } }

Demo 2: User Profile Component

Display component showing user information with profile stats. Demonstrates how custom elements can create a clear information hierarchy.

Custom Elements Used

  • <user-profile> - Profile container
  • <profile-avatar> - User image
  • <profile-info> - User information wrapper
  • <user-name> - Display name
  • <user-role> - Job title/role
  • <user-email> - Email address
  • <profile-stats> - Statistics container
  • <stat-item> - Individual stat

HTML Structure

<user-profile role="article" tabindex="0"> <profile-avatar> <img src="https://placehold.co/80x80" alt="Jane Smith"> </profile-avatar> <profile-info> <user-name>Jane Smith</user-name> <user-role>Senior Developer</user-role> <user-email> <a href="mailto:jane@example.com">jane@example.com</a> </user-email> </profile-info> <profile-stats> <stat-item> <stat-label>Projects</stat-label> <stat-value>24</stat-value> </stat-item> <stat-item> <stat-label>Contributions</stat-label> <stat-value>1.2k</stat-value> </stat-item> </profile-stats> </user-profile>

Live Demo

CSS Architecture Tips

To use custom elements well consider adopting modern CSS features including:

  • Tokens (CSS variables) for consistent spacing, colors, and typography
  • CSS Layers for organized cascade control
  • CSS Nesting for encapsulation
  • Relative values for fluidity
  • Container queries for responsive behavior

Self-Contained Demo Styles

/* Each demo on this page is a single, self-contained file โ€” no shared token file, no @import chain. Literal values stand in for what a larger app would keep in CSS custom properties: */ product-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }

CSS Layers

Layers control cascade specificity:

@layer reset, base, layout, components, utilities; @layer base { body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; } } @layer components { product-card { /* Component styles */ } }

Key Takeaways

Next Steps

In Step 2: Custom elements, we'll add JavaScript to enhance these elements:

  • Register elements with customElements.define()
  • Create proper classes extending HTMLElement
  • Split setup between the constructor and connectedCallback
  • See what "upgrade" means when the definition arrives after the markup
  • Compare autonomous elements with customized built-ins

Step 02

Custom elements

Semantic markers become real behavior here. customElements.define() associates an element name with a class extending HTMLElement, and the browser starts calling its lifecycle callbacks. The line that trips people up most is the constructor versus connectedCallback split โ€” one runs before the element is anywhere near the document, the other runs once it is, and mixing them up is the most common source of "why is this null" bugs in a first custom element.

Learning Objectives

  • Register custom elements using customElements.define() to make them web components
  • Understand element constructor patterns
  • Introduce the basics of the lifecycle of web components
  • Implement common DOM manipulation in and using web components

The Custom Elements API

The Custom Elements API allows you to define new HTML elements with JavaScript logic. You register a custom element and associate it with a class:

customElements.define('my-element', MyElement);

Basic Custom Element Class

Every custom element extends HTMLElement and must call super() first in the constructor:

class MyElement extends HTMLElement { constructor() { super(); // Must call super() first! // Element initialization this._data = {}; // Underscore convention for "private" properties } connectedCallback() { // Called when element is added to the document this.render(); } render() { // Update element content this.innerHTML = `

Hello from MyElement!

`; } } // Register the element customElements.define('my-element', MyElement);

Registration Requirements

  • Class-based definition: Must use ES6 classes
  • Extends HTMLElement: Or a specific HTML element if the idea was fully realized (not sadly due to Apple holding out)
  • Super call: Constructor must call super() first
  • Registration: Use customElements.define()

Constructor vs connectedCallback

  • Constructor

    For setup that doesn't touch the Light DOM.

    constructor() { super(); // Initialize properties this._count = 0; this._name = 'Default'; // Setup internal state this._data = {}; }

    Use with Shadow DOM

  • connectedCallback

    For Light DOM manipulation and setup.

    connectedCallback() { // Read attributes this._name = this.getAttribute('name'); // Manipulate DOM this.render(); // Attach event listeners this.attachEventListeners(); }

    Mostly for Light DOM solutions

Demo 1: Simple Greeting Element

A basic custom element that reads an attribute of the element and displays a default or custom greeting.

JavaScript Implementation

class GreetingElement extends HTMLElement { constructor() { super(); this._name = 'World'; // Default value } connectedCallback() { // Read the 'name' attribute this._name = this.getAttribute('name') || 'World'; this.render(); } render() { this.textContent = `Hello, ${this._name}! ๐Ÿ‘‹`; } } // Register the element customElements.define('greeting-element', GreetingElement);

HTML Usage

<!-- With default name --> <greeting-element></greeting-element> <!-- With custom name attribute --> <greeting-element name="Again"></greeting-element> <!-- Child text here is discarded: render() overwrites it with textContent --> <greeting-element name="Yet Again">This child text gets overwritten!</greeting-element>

Live Demo

Demo 2: Interactive Counter Element

A custom element with internal state and event handling. Demonstrates the full lifecycle of a component with user interaction.

JavaScript Implementation

class CounterElement extends HTMLElement { constructor() { super(); this._count = 0; } connectedCallback() { // Get initial count from attribute const initial = this.getAttribute('initial'); this._count = initial ? parseInt(initial, 10) : 0; this.render(); this.attachEventListeners(); } render() { this.innerHTML = ` <div class="counter-container"> <button class="decrement">-</button> <span class="count">${this._count}</span> <button class="increment">+</button> </div> `; } attachEventListeners() { const decrementBtn = this.querySelector('.decrement'); const incrementBtn = this.querySelector('.increment'); decrementBtn.addEventListener('click', () => { this._count--; this.updateCount(); }); incrementBtn.addEventListener('click', () => { this._count++; this.updateCount(); }); } updateCount() { // Only update the count display, not the entire element const countDisplay = this.querySelector('.count'); countDisplay.textContent = this._count; } } customElements.define('counter-element', CounterElement);

HTML Usage

<!-- Counter starting at 0 --> <counter-element initial="0"></counter-element> <!-- Counter starting at 10 --> <counter-element initial="10"></counter-element>

Live Demo

Demo 3: User Card from slot Attributes

This component takes its content from its children instead of from attributes: each child carries a slot="โ€ฆ" attribute saying which part of the card it belongs to, and the component reads those children and builds the card from them.

JavaScript Implementation

class UserCard extends HTMLElement { connectedCallback() { this.render(); } render() { // Read the marked children *before* overwriting them, since the // innerHTML assignment below destroys the originals. const name = this.querySelector('[slot="name"]')?.textContent || 'Unknown'; const role = this.querySelector('[slot="role"]')?.textContent || 'No role'; const email = this.querySelector('[slot="email"]')?.textContent || 'No email'; this.innerHTML = ` <div class="card-header">${name}</div> <div class="card-info"><strong>Role:</strong> ${role}</div> <div class="card-info"><strong>Email:</strong> ${email}</div> `; } } customElements.define('user-card', UserCard);

HTML Usage

<user-card> <span slot="name">John Doe</span> <span slot="role">Software Engineer</span> <span slot="email">john@example.com</span> </user-card>

Live Demo

When Elements Upgrade

Elements can be "upgraded" (have their behavior added) at different times:

  • Elements in HTML before registration: Upgraded when defined
  • Elements created with document.createElement(): Upgraded when appended
  • Elements parsed after registration: Upgraded immediately
  • Elements cloned from templates: Upgraded when added to DOM

Checking Registration Status

// Check if element is defined if (customElements.get('my-element')) { console.log('Element is registered'); } // Wait for element definition customElements.whenDefined('my-element').then(() => { console.log('Element is now defined and ready'); }); // Check if an element has been upgraded const element = document.querySelector('my-element'); if (element instanceof MyElement) { console.log('Element has been upgraded'); }

Demonstration

<!-- This element exists before definition --> <delayed-element> I'm waiting to be upgraded. My behavior will be added when registered. </delayed-element> <button id="register-delayed">Register Delayed Element</button> <script> document.getElementById('register-delayed').addEventListener('click', () => { class DelayedElement extends HTMLElement { connectedCallback() { this.classList.add('upgraded'); this.innerHTML = ` <strong>Woo hoo I've been upgraded!</strong><br> I'm now a proper custom element with behavior. `; } } customElements.define('delayed-element', DelayedElement); }); </script>

Live Demo

This frame combines the delayed-registration demo with the registration status list below, so clicking the button shows both effects at once: the element upgrading in place, and its status flipping from โœ— Not Registered to โœ“ Registered.

Waiting for a definition

Upgrade is asynchronous, so "is this element ready?" is a real question with two different answers. customElements.get() asks it synchronously; customElements.whenDefined() returns a promise that resolves whenever the definition lands, even if that is later.

// Synchronous: is it defined right now? if (!customElements.get('user-card')) { console.log('not registered yet'); } // Asynchronous: tell me when it is, whenever that happens await customElements.whenDefined('user-card'); document.querySelector('user-card').refresh();

CSS asks the same question with :defined, which is how you avoid a flash of un-upgraded content while the defining script is still loading.

user-card:not(:defined) { visibility: hidden; }

Registration Status

You can programmatically check which custom elements have been registered using the global customElements object โ€” an instance of the CustomElementRegistry interface โ€” in this case its get() method. Reload the page if you already registered it and see it showing as "Not Registered" and then register it to see it show the new state.

Implementation

class RegistrationStatus extends HTMLElement { connectedCallback() { this.render(); } render() { const elements = [ 'greeting-element', 'counter-element', 'user-card', 'delayed-element', 'registration-status' ]; const statusHTML = elements.map(name => { const isRegistered = customElements.get(name); const status = isRegistered ? 'โœ“ Registered' : 'โœ— Not Registered'; const className = isRegistered ? 'registered' : 'not-registered'; return `<li class="${className}"><code>${name}</code>: ${status}</li>`; }).join(''); this.innerHTML = ` <div class="registration-status"> <h4>Custom Elements Registry:</h4> <ul>${statusHTML}</ul> </div> `; } } customElements.define('registration-status', RegistrationStatus);

See it in action in the live demo at the end of the previous section โ€” that frame includes a <registration-status> element alongside the delayed-registration button, so registering delayed-element updates this list in real time.

Dynamic Element Creation

Custom elements can be created programmatically using document.createElement(), just like native HTML elements. Remember, the DOM manipulates arbitrary elements and custom elements are included. In a sense the DOM is a complete framework API for HTML plus whatever you invent. That shouldn't surprise you because the frameworks ultimately use it to do their job. Such things may provide better developer ergonomics, but fundamentally they rely on all the DOM concepts you should understand.

Implementation

// Create a custom element const counter = document.createElement('counter-element'); // Set attributes before adding to DOM counter.setAttribute('initial', '0'); // Add to the DOM (triggers connectedCallback) document.getElementById('container').appendChild(counter); // Or create and configure in one step const greeting = document.createElement('greeting-element'); greeting.setAttribute('name', 'Dynamic'); document.body.appendChild(greeting);

Nothing about a custom element requires it to start out in the HTML โ€” createElement() plus appendChild() triggers the same connectedCallback that parsing the tag from markup does.

Autonomous vs Customized Built-in Elements

  • โœ… Autonomous Elements

    Extend HTMLElement directly

    class MyElement extends HTMLElement { // Your code } customElements.define('my-element', MyElement);

    Use in HTML:

    <my-element></my-element>

    โœ… Fully supported across all browsers

  • โŒ Customized Built-in Elements

    Extend specific elements (e.g., HTMLButtonElement)

    class FancyButton extends HTMLButtonElement { // Your code } customElements.define('fancy-button', FancyButton, { extends: 'button' });

    Use in HTML:

    <button is="fancy-button"></button>

    โŒ Limited browser support - avoid using

Best Practices

Next Steps

In Step 3: Component basics, we'll explore:

  • Attributes versus properties, and when to use which
  • Reflection: keeping the two in sync
  • observedAttributes and attributeChangedCallback
  • Custom states with ElementInternals and CSS :state()
  • Component API design โ€” and when HTML already has the element you are rebuilding

Step 03

Component basics

A custom element's public surface is a design decision, not just a technical one. This step is about that surface: the split between attributes (strings, visible in markup, easy to style from CSS) and properties (any JavaScript value, invisible in markup, set only from code), and reflection โ€” the discipline of keeping the two in sync so an element behaves the way a native one does. observedAttributes and attributeChangedCallback let a component react when its markup changes out from under it, and ElementInternals's custom states give you a third option: state CSS can match with :state() that nothing outside the component can set directly.

Learning Objectives

  • Create functional web components with properties and methods
  • Understand constructor() vs connectedCallback() usage
  • Implement attribute reflection and property binding
  • Build reusable components to explore component design
  • Discover that many components are reinventions or should leverage existing HTML elements

Properties vs Attributes

Understanding the distinction between properties and attributes is fundamental to creating well-designed web components.

  • Attributes

    • String values in HTML
    • Visible in markup
    • Easily bound to CSS
    • Set via HTML or setAttribute()
    • Always strings (or null) though remember you can stringify things
    <my-element value="42" disabled></my-element>
  • Properties

    • JavaScript object properties
    • Not visible in markup
    • Not bindable with CSS easily
    • Set via JavaScript
    • Can be any type
    element.value = 42; // Number element.disabled = true; // Boolean element.data = { foo: 'bar' }; // Object

Observing Attribute Changes

Components can react to attribute changes using the observedAttributes and attributeChangedCallback lifecycle method.

class MyElement extends HTMLElement { // Define which attributes to observe static get observedAttributes() { return ['value', 'disabled', 'theme']; } // Called when observed attributes change attributeChangedCallback(name, oldValue, newValue) { console.log(`${name} changed from ${oldValue} to ${newValue}`); // Update component based on attribute change if (name === 'value') { this._value = newValue; this.render(); } } }

Property Getters and Setters

Use getters and setters to create a clean API and maintain synchronization between properties and attributes.

Basic Property Pattern

class MyElement extends HTMLElement { get value() { return this._value; } set value(newValue) { this._value = newValue; // Reflect to attribute this.setAttribute('value', newValue); // Re-render this.render(); } } // Usage element.value = 42; // Calls setter, updates attribute

Boolean Properties

Boolean attributes follow a special pattern - they're present or absent, not true/false strings. Interestingly, JavaScript's weak typing kind of helps (or hurts) you here as non-empty strings are "true" and blank ones are false regardless of value. Conventionally, you may see folks using the value "true" or if following XHTML the name of the attribute disabled="disabled", but it is just the presence that matters so remove it if you are false don't set it to "false" as that actually will convert to true in JavaScript!

get disabled() { return this.hasAttribute('disabled'); } set disabled(value) { if (value) { this.setAttribute('disabled', ''); } else { this.removeAttribute('disabled'); } this.render(); }

Usage Examples

<!-- Boolean attributes: present or absent --> <my-element disabled></my-element> <!-- disabled = true --> <my-element></my-element> <!-- disabled = false -->

Custom states

Reflecting to an attribute makes internal state visible to CSS, but it also makes it public: an attribute is part of your API, and anyone can set it. ElementInternals gives you the other option โ€” a private state set, matched from CSS with :state(), that nothing outside the component can write.

class ToggleSwitch extends HTMLElement { #internals = this.attachInternals(); get checked() { return this.#internals.states.has('checked'); } set checked(value) { if (value) this.#internals.states.add('checked'); else this.#internals.states.delete('checked'); } } toggle-switch:state(checked) { background: seagreen; }

Demo 1: Toggle Switch Component

A fully functional toggle switch demonstrating properties, attributes, events, and state management.

Features

  • checked - Boolean property/attribute for toggle state
  • disabled - Boolean property/attribute to disable interaction
  • label - String property/attribute for accessibility
  • change - Custom event fired when toggled

Implementation

class ToggleSwitch extends HTMLElement { static get observedAttributes() { return ['checked', 'disabled', 'label']; } constructor() { super(); this._checked = false; this._disabled = false; } get checked() { return this._checked; } set checked(value) { this._checked = Boolean(value); if (this._checked) { this.setAttribute('checked', ''); } else { this.removeAttribute('checked'); } this.render(); } get disabled() { return this._disabled; } set disabled(value) { this._disabled = Boolean(value); if (this._disabled) { this.setAttribute('disabled', ''); } else { this.removeAttribute('disabled'); } this.render(); } connectedCallback() { this._checked = this.hasAttribute('checked'); this._disabled = this.hasAttribute('disabled'); this.render(); this.attachEventListeners(); } render() { const label = this.getAttribute('label') || ''; this.innerHTML = ` <label class="toggle-container"> ${label ? `<span class="toggle-label">${label}</span>` : ''} <span class="toggle-switch ${this._checked ? 'checked' : ''} ${this._disabled ? 'disabled' : ''}"> <span class="toggle-slider"></span> </span> </label> `; } attachEventListeners() { const toggleSwitch = this.querySelector('.toggle-switch'); toggleSwitch.addEventListener('click', () => { if (!this._disabled) { this.checked = !this._checked; this.dispatchEvent(new CustomEvent('change', { detail: { checked: this._checked } })); } }); } } customElements.define('toggle-switch', ToggleSwitch);

Live Demo

Four toggles cover the states above โ€” default, pre-checked, disabled, and labeled โ€” an event log records every change event, and a small API panel drives the first toggle from JavaScript instead of a click, the same programmatic control the "Interactive API Demo" section below explains.

Demo 2: Progress Bar Component

A progress bar with dynamic updates, demonstrating numeric property handling, validation, and custom events.

Features

  • value - Numeric property for current progress value
  • max - Maximum value (default: 100)
  • color - String property for bar color customization
  • show-percentage - Boolean to display percentage text
  • percentage - Computed getter for percentage value
  • progress-change - Custom event fired when value changes
  • Automatic clamping of values to valid range (0 to max)
  • Proper ARIA attributes for accessibility

Implementation

class ProgressBar extends HTMLElement { constructor() { super(); this._value = 0; this._max = 100; this._color = '#2563eb'; this._showPercentage = false; } static get observedAttributes() { return ['value', 'max', 'color', 'show-percentage']; } connectedCallback() { this._value = parseInt(this.getAttribute('value') || '0', 10); this._max = parseInt(this.getAttribute('max') || '100', 10); this._color = this.getAttribute('color') || '#2563eb'; this._showPercentage = this.hasAttribute('show-percentage'); this.render(); } attributeChangedCallback(name, oldValue, newValue) { switch (name) { case 'value': { // Braces give the case its own block, so `const` is scoped to it const val = parseInt(newValue || '0', 10); this._value = Math.max(0, Math.min(val, this._max)); break; } case 'max': this._max = parseInt(newValue || '100', 10); break; case 'color': this._color = newValue || '#2563eb'; break; case 'show-percentage': this._showPercentage = newValue !== null; break; } if (this.isConnected) { this.render(); } } get value() { return this._value; } set value(newValue) { const value = Math.max(0, Math.min(parseInt(newValue, 10), this._max)); if (this._value !== value) { this._value = value; this.setAttribute('value', value); this.render(); // Dispatch custom event this.dispatchEvent(new CustomEvent('progress-change', { detail: { value: this._value, percentage: this.percentage }, bubbles: true })); } } get max() { return this._max; } set max(newValue) { const max = Math.max(1, parseInt(newValue, 10)); if (this._max !== max) { this._max = max; this.setAttribute('max', max); this.render(); } } get percentage() { return Math.round((this._value / this._max) * 100); } render() { const percentage = this.percentage; this.innerHTML = ` <div class="progress-container"> <div class="progress-fill" style="width: ${percentage}%; background: ${this._color}"></div> ${this._showPercentage ? `<div class="progress-text">${percentage}%</div>` : ''} </div> `; // Update ARIA attributes for accessibility this.setAttribute('role', 'progressbar'); this.setAttribute('aria-valuenow', this._value); this.setAttribute('aria-valuemin', '0'); this.setAttribute('aria-valuemax', this._max); } } customElements.define('progress-bar', ProgressBar);

HTML Usage

<!-- Basic progress bar with default max of 100 --> <progress-bar value="30" show-percentage></progress-bar> <!-- Custom color --> <progress-bar value="75" color="#10b981" show-percentage></progress-bar> <!-- Custom max value (50 out of 200 = 25%) --> <progress-bar value="50" max="200" color="#f59e0b" show-percentage></progress-bar> <!-- Without percentage text display --> <progress-bar value="60" color="#8b5cf6"></progress-bar> <!-- Listen to progress changes --> <script> const progressBar = document.querySelector('progress-bar'); progressBar.addEventListener('progress-change', (e) => { console.log('Value:', e.detail.value); console.log('Percentage:', e.detail.percentage); }); </script>

Live Demo

Four bars show fixed values across the range and color options, plus an "Animate" button that ramps a fifth bar from 0 to 100 and back, one value setter call at a time.

Demo 3: Tab Container Component

A tab container demonstrating dynamic content switching, event delegation, and parent-child component communication. This is a common demo that shows a nice, but needless component?

Features

  • Multiple tab panels with labeled navigation
  • Active state management
  • Event delegation for tab switching
  • ARIA attributes for accessibility

Implementation

class TabContainer extends HTMLElement { connectedCallback() { this.render(); this.attachEventListeners(); } render() { const panels = Array.from(this.querySelectorAll('tab-panel')); // Build tab navigation const tabs = panels.map((panel, index) => { const label = panel.getAttribute('label') || `Tab ${index + 1}`; const active = panel.hasAttribute('active'); return ` <button class="tab ${active ? 'active' : ''}" data-index="${index}" role="tab" aria-selected="${active}"> ${label} </button> `; }).join(''); // Wrap panels in container const panelsHTML = panels.map((panel, index) => { const active = panel.hasAttribute('active'); return ` <div class="tab-content ${active ? 'active' : ''}" role="tabpanel" data-index="${index}"> ${panel.innerHTML} </div> `; }).join(''); this.innerHTML = ` <div class="tab-navigation" role="tablist">${tabs}</div> <div class="tab-panels">${panelsHTML}</div> `; } attachEventListeners() { const tabNav = this.querySelector('.tab-navigation'); tabNav.addEventListener('click', (e) => { if (e.target.classList.contains('tab')) { this.switchTab(parseInt(e.target.dataset.index)); } }); } switchTab(index) { // Update tab buttons this.querySelectorAll('.tab').forEach((tab, i) => { tab.classList.toggle('active', i === index); tab.setAttribute('aria-selected', i === index); }); // Update tab content this.querySelectorAll('.tab-content').forEach((content, i) => { content.classList.toggle('active', i === index); }); } } class TabPanel extends HTMLElement { // Placeholder for tab panels - content is extracted by TabContainer } customElements.define('tab-container', TabContainer); customElements.define('tab-panel', TabPanel);

HTML Usage

<tab-container> <tab-panel label="Overview" active> <h3>Overview Content</h3> <p>First tab content...</p> </tab-panel> <tab-panel label="Features"> <h3>Features</h3> <ul> <li>Feature 1</li> <li>Feature 2</li> </ul> </tab-panel> <tab-panel label="Code Example"> <h3>Code Example</h3> <pre><code>// Your code here</code></pre> </tab-panel> </tab-container>

Live Demo

Interactive API Demo

Interact with component APIs programmatically using JavaScript. This demonstrates how to control components from external code. The DOM features will already be there, but you might want your own. We will revisit the toggle component even though we don't need to make one to show how you could do that.

Programmatic Control Example

// Get reference to the component const toggle = document.getElementById('api-toggle'); // Toggle the checked state toggle.checked = !toggle.checked; // Toggle the disabled state toggle.disabled = !toggle.disabled; // Get current state console.log('Checked:', toggle.checked); console.log('Disabled:', toggle.disabled); // Listen for changes toggle.addEventListener('change', (e) => { console.log('State changed:', e.detail); });

Live Demo

See it in action in the toggle switch demo above โ€” that frame's own Toggle Checked, Toggle Disabled, and Get State buttons call exactly the setters and getters shown here, so clicking a switch and clicking a button drive the same checked/disabled property pair.

Component API Design Best Practices

Well-designed components have clear, predictable APIs that are easy to use and understand. You'll likely want to lean into what is assumed understood by the developer and of course have documentation

Example: Input Validation

set value(val) { // Convert string to number let numValue = parseFloat(val); // Validate - provide sensible default if (isNaN(numValue)) { numValue = 0; } // Clamp to valid range this._value = Math.min(this.max, Math.max(this.min, numValue)); // Reflect to attribute this.setAttribute('value', this._value.toString()); // Update UI this.render(); }

Next Steps

In Step 4: Shadow DOM considerations, we'll explore:

  • What the shadow boundary buys, and what it costs
  • Open vs. closed shadow roots
  • When to use shadow DOM vs. light DOM
  • Styling strategies across the boundary: custom properties, ::part, and ::slotted

Step 04

Shadow DOM considerations

A shadow root gives an element its own DOM subtree with its own style scope: the page's CSS cannot reach in, and the component's CSS cannot leak out. That isolation is the whole feature, and it is also why shadow DOM is wrong for most application-level markup โ€” SEO, form participation, and some accessibility tooling all expect one continuous document, not a boundary they cannot see past. This step covers what the boundary buys, what it costs, open versus closed roots, and the deliberate holes โ€” ::part, ::slotted, and custom properties โ€” that let a component author open specific gaps in it on purpose.

Learning Objectives

  • Understand Shadow DOM and light DOM and differences
  • Learn when to use Shadow DOM vs light DOM
  • Compare open vs closed shadow roots
  • Explore ideas to poke into the shadow with styles

What is Shadow DOM?

Shadow DOM provides encapsulation for web components by creating a separate DOM tree that's attached to an element but isolated from the main document tree. The main advantage of this is to protect the markup, style, and script (mostly events) from other parts of the page.

Core Features

  • Style Isolation: Styles don't leak out and only somewhat in (with effort)
  • DOM Encapsulation: Internal structure is hidden
  • Scoped Events: Some events are scoped to shadow tree
class MyElement extends HTMLElement { constructor() { super(); // Attach shadow root this.attachShadow({ mode: 'open' }); } connectedCallback() { // Add content to shadow root this.shadowRoot.innerHTML = ` <style> :host { display: block; padding: 1rem; } .content { color: blue; } </style> <div class="content"> Shadow DOM content </div> `; } } customElements.define('my-element', MyElement);

Benefits of Shadow DOM

  • โœ… Style Encapsulation

    CSS defined inside shadow DOM doesn't affect the rest of the page, and external CSS doesn't affect the shadow DOM.

    /* Inside shadow DOM */ .button { color: blue; /* Won't conflict with page .button */ }
  • โœ… Implementation Hiding

    Internal DOM structure is hidden from external JavaScript, protecting implementation details.

    // External code can't access internals element.querySelector('.internal'); // null // Must use shadowRoot element.shadowRoot.querySelector('.internal'); // โœ“
  • โœ… Composition via Slots

    Slots enable flexible content projection while maintaining encapsulation.

    <my-card> <span slot="title">Card Title</span> <p>Card content</p> </my-card>
  • โœ… Scoped Events

    Some events are retargeted to maintain encapsulation boundaries.

    // Event target is the host element // not the internal element that was clicked element.addEventListener('click', (e) => { console.log(e.target); // <my-element> });

Limitations of Shadow DOM

Key Limitations

  • Styling Complexity: External styling requires CSS custom properties or parts
  • Form Participation: Shadow DOM elements don't automatically participate in forms
  • Accessibility Challenges: Some screen readers and tools struggle with shadow boundaries
  • Global Styles: Utility classes and global styles don't penetrate shadow boundaries
  • Third-party Integration: Libraries that rely on global selectors won't work
  • Bot Considerations: Content in shadow DOM may not be indexed as effectively or worked with bots, though arguably most JavaScript driven content can suffer this problem as well


Example: External Styling Limitation

/* This external CSS won't style shadow DOM internals */ my-element .internal-class { color: red; /* โŒ Won't work */ } /* Must use CSS custom properties */ my-element { --text-color: red; /* โœ“ Can be used inside if that variable is used within (var values bleed in) */ }

Open vs Closed Shadow DOM

When attaching a shadow root, you must specify whether it's open or closed.

  • โœ… Open (Recommended)

    constructor() { super(); this.attachShadow({ mode: 'open' }); } // Accessible from outside element.shadowRoot // Returns shadowRoot

    Pros:

    • Debuggable in DevTools
    • Testable
    • Extensible
    • Standard practice
  • โŒ Closed (Rarely Needed)

    constructor() { super(); this.attachShadow({ mode: 'closed' }); } // Not accessible from outside element.shadowRoot // null

    Cons:

    • Harder to debug
    • Can't test easily
    • Not actually secure
    • Limits flexibility

Demo: Shadow DOM vs Light DOM Comparison

Compare the same message banner component implemented with and without Shadow DOM to see the practical differences.

Light DOM Implementation

class MessageBannerLight extends HTMLElement { connectedCallback() { const type = this.getAttribute('type') || 'info'; const content = this.textContent; // Renders to light DOM this.innerHTML = ` <div class="banner banner-${type}"> <span class="banner-icon">โ„น๏ธ</span> <span class="banner-content">${content}</span> </div> `; } } customElements.define('message-banner-light', MessageBannerLight);

Shadow DOM Implementation

class MessageBannerShadow extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { const type = this.getAttribute('type') || 'info'; // Renders to shadow DOM with encapsulated styles this.shadowRoot.innerHTML = ` <style> :host { display: block; margin: 1rem 0; } .banner { padding: 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; } .banner-info { background: #dbeafe; } .banner-success { background: #d1fae5; } .banner-warning { background: #fef3c7; } </style> <div class="banner banner-${type}"> <span class="banner-icon">โ„น๏ธ</span> <span class="banner-content"><slot></slot></span> </div> `; } } customElements.define('message-banner-shadow', MessageBannerShadow);

Live Comparison

Both banners render the same content through the same type attribute. The page in the frame below also carries one global rule targeting .banner-content โ€” watch which banner it reaches.

When to Use Shadow DOM

Choosing between Shadow DOM and light DOM depends on your specific use case and requirements.

  • โœ… Use Shadow DOM When:

    • Style isolation is critical
    • Building reusable component libraries
    • Need implementation hiding
    • Want slot-based composition
    • Building standalone widgets
    • Creating design system components
  • โŒ Avoid Shadow DOM When:

    • Need deep CSS customization
    • Working with form elements
    • Require global styles to apply
    • Building application-specific components
    • SEO is critical (SSR)
    • Need maximum accessibility

Styling Strategies for Shadow DOM

When using Shadow DOM, you need strategies to allow customization while maintaining encapsulation.

1. CSS Custom Properties (Variables)

/* Inside shadow DOM */ :host { background: var(--button-bg, #2563eb); color: var(--button-text, white); } /* External customization */ my-button { --button-bg: #10b981; --button-text: white; }

2. CSS Parts (::part)

<!-- Inside shadow DOM --> <div part="container"> <button part="button">Click</button> </div> /* External styling */ my-element::part(button) { background: red; }

3. Host Context (:host-context) โ€” Chromium only

/* Style based on ancestor */ :host-context(.dark-theme) { background: #1f2937; color: white; } :host-context(.light-theme) { background: white; color: #1f2937; }

The three deliberate holes

Encapsulation cuts both ways. The page cannot reach in, which is the point, and is also the problem the first time somebody wants your button in their brand color. The platform's answer is not to weaken the boundary but to let the component open specific holes in it.

<!-- inside the shadow root: opt a node in to outside styling --> <button part="submit">Send</button> /* 1. ::part โ€” style a node the component deliberately exposed */ my-form::part(submit) { background: rebeccapurple; } /* 2. ::slotted โ€” style light-DOM children the page passed in */ :host ::slotted(p) { margin: 0; } /* 3. Custom properties โ€” these cross the boundary by design */ :host { background: var(--my-form-bg, white); }

Best Practices

Next Steps

In Step 5: Component lifecycle, we'll explore:

  • All five lifecycle callbacks, including the rarely-seen adoptedCallback
  • Constructor rules and restrictions
  • Resource management: timers, observers, and event listeners
  • Memory leak prevention, and one AbortController that removes every listener at once
  • Handling elements that connect and disconnect more than once

Step 05

Component lifecycle

A custom element has five lifecycle callbacks, and most of the bugs people hit come from treating only two of them as real: constructor and connectedCallback. The other three โ€” disconnectedCallback, attributeChangedCallback, and the rarely-seen adoptedCallback โ€” are where resource management and cleanup live. This step walks through the full set, the timing between them, and the listener that outlives its element because nobody removed it.

Learning Objectives

  • Master all lifecycle callbacks and their purposes
  • Understand timing and sequencing of callbacks
  • Implement proper cleanup patterns
  • Handle edge cases and memory management

Lifecycle Callbacks Overview

Custom elements have five lifecycle callbacks that allow you to hook into different stages of an element's life. Understanding when and how each callback is triggered is crucial for building robust components.

The Five Lifecycle Callbacks

  • constructor() - Element is created (instantiated)
  • connectedCallback() - Element added to the DOM
  • disconnectedCallback() - Element removed from the DOM
  • attributeChangedCallback() - Observed attribute changed
  • adoptedCallback() - Element moved to new document (rare)

Callback Timing and Flow

// Creation flow: // 1. constructor() - Element instantiated // 2. attributeChangedCallback() - For any attributes set initially // 3. connectedCallback() - When added to DOM const element = document.createElement('my-element'); // constructor() element.setAttribute('value', '42'); // attributeChangedCallback() document.body.appendChild(element); // connectedCallback() // Removal flow: document.body.removeChild(element); // disconnectedCallback() // Attribute change flow (while connected): element.setAttribute('value', '100'); // attributeChangedCallback()

The fourth callback

adoptedCallback is the one nobody mentions, because it fires rarely: only when an element is moved into a different document, such as document.adoptNode() into an iframe. It matters when it happens, because document-scoped things the element captured earlier โ€” observers, stylesheets, references to document โ€” are now pointing at the wrong document.

adoptedCallback(oldDocument, newDocument) { // Re-acquire anything that was scoped to the old document. }

Constructor Rules and Restrictions

The constructor has strict rules defined by the HTML specification. Following these rules prevents errors and ensures consistent behavior.

Correct Constructor Pattern

class MyElement extends HTMLElement { constructor() { super(); // Must be first! // โœ… Initialize private state this._isConnected = false; this._data = {}; this._value = 0; // โœ… Set up internal references this._handleResize = this._handleResize.bind(this); // โŒ Don't do this in constructor: // this.innerHTML = '...'; // DOM manipulation // this.getAttribute('attr'); // Reading attributes // this.setAttribute('attr', 'val'); // Setting attributes // this.appendChild(child); // Adding children } connectedCallback() { // โœ… Do DOM work here instead this.render(); this.attachEventListeners(); } }

Connected vs Disconnected Callbacks

These callbacks are called when an element is added to or removed from the document. They're your opportunity to set up and tear down resources.

  • connectedCallback()

    Called when element is added to the document:

    connectedCallback() { // โœ… Access parent/children this._parent = this.parentElement; // โœ… Set up event listeners this.addEventListener('click', this._handleClick); window.addEventListener('resize', this._handleResize); // โœ… Start intervals/observers this._interval = setInterval(() => { this.update(); }, 1000); this._observer = new ResizeObserver(() => { this.handleResize(); }); this._observer.observe(this); // โœ… Render initial content this.render(); }
  • disconnectedCallback()

    Called when element is removed from the document:

    disconnectedCallback() { // โœ… Remove event listeners this.removeEventListener('click', this._handleClick); window.removeEventListener('resize', this._handleResize); // โœ… Clear intervals if (this._interval) { clearInterval(this._interval); this._interval = null; } // โœ… Disconnect observers if (this._observer) { this._observer.disconnect(); this._observer = null; } // โœ… Release resources this._data = null; }

Attribute Observation

The attributeChangedCallback is only called for attributes listed in observedAttributes. This improves performance by avoiding unnecessary callbacks.

Observing Attributes

class MyElement extends HTMLElement { // Define which attributes to observe static get observedAttributes() { return ['value', 'disabled', 'theme']; } attributeChangedCallback(name, oldValue, newValue) { // Called when value, disabled, or theme changes console.log(`${name} changed from "${oldValue}" to "${newValue}"`); // Update based on which attribute changed switch(name) { case 'value': this._value = parseFloat(newValue) || 0; this.updateDisplay(); break; case 'disabled': this._disabled = newValue !== null; this.updateState(); break; case 'theme': this.updateTheme(newValue); break; } } // Property setter that reflects to attribute set value(val) { this.setAttribute('value', val); // Triggers attributeChangedCallback } }

Demo 1: Lifecycle Visualization

Watch lifecycle callbacks fire in real-time as you interact with components. This demo logs every callback execution so you can see the exact order and timing.

The shape of a logging element

A sketch of the idea โ€” every callback announces itself. The frame below runs the same pattern across three different elements (auto-clock, resize-tracker, network-status) logging straight to the console.

class LifecycleElement extends HTMLElement { static get observedAttributes() { return ['value']; } constructor() { super(); this.log('constructor'); this._value = 0; } connectedCallback() { this.log('connectedCallback'); this.render(); } disconnectedCallback() { this.log('disconnectedCallback'); } attributeChangedCallback(name, oldValue, newValue) { this.log(`attributeChangedCallback: ${name} = ${newValue}`); } adoptedCallback() { this.log('adoptedCallback'); } log(message) { const event = new CustomEvent('lifecycle-event', { detail: { message, element: this }, bubbles: true }); this.dispatchEvent(event); } render() { this.innerHTML = `<div class="lifecycle-box">Lifecycle Element ${this._value}</div>`; } } customElements.define('lifecycle-element', LifecycleElement);

Live Demo

Buttons drive a real auto-clock element through create, add, move, attribute change, and remove. A resize tracker and a network status indicator can each be toggled on and off the same way. Every callback logs to the console below the frame.

Resource Management Patterns

Proper resource management is critical for preventing memory leaks and ensuring components behave correctly when added, removed, and re-added to the DOM.

Pattern 1: Timer Management

class AutoClock extends HTMLElement { connectedCallback() { // Start timer when connected this._interval = setInterval(() => { this.updateTime(); }, 1000); this.updateTime(); } disconnectedCallback() { // Stop timer when disconnected if (this._interval) { clearInterval(this._interval); this._interval = null; } } updateTime() { const format = this.getAttribute('format') || 'short'; const options = format === 'long' ? { hour: '2-digit', minute: '2-digit', second: '2-digit' } : { hour: '2-digit', minute: '2-digit' }; this.textContent = new Date().toLocaleTimeString('en-US', options); } } customElements.define('auto-clock', AutoClock);

Pattern 2: Observer Management

class ResizeTracker extends HTMLElement { connectedCallback() { // Create and start observer this._observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; this.updateSize(width, height); }); this._observer.observe(this); this.render(); } disconnectedCallback() { // Disconnect observer if (this._observer) { this._observer.disconnect(); this._observer = null; } } updateSize(width, height) { const output = this.querySelector('.size-output'); if (output) { output.textContent = `${Math.round(width)}px ร— ${Math.round(height)}px`; } } render() { this.innerHTML = ` <div class="resize-box" style="resize: both; overflow: auto; border: 2px solid #ccc; padding: 20px; min-width: 200px; min-height: 100px;"> <div class="size-output">Resize me!</div> </div> `; } } customElements.define('resize-tracker', ResizeTracker);

Pattern 3: Event Listener Management

class NetworkStatus extends HTMLElement { constructor() { super(); // Bind methods in constructor for easy cleanup this._handleOnline = this._handleOnline.bind(this); this._handleOffline = this._handleOffline.bind(this); } connectedCallback() { // Add global event listeners window.addEventListener('online', this._handleOnline); window.addEventListener('offline', this._handleOffline); this.updateStatus(); } disconnectedCallback() { // Remove global event listeners window.removeEventListener('online', this._handleOnline); window.removeEventListener('offline', this._handleOffline); } _handleOnline() { this.updateStatus(); } _handleOffline() { this.updateStatus(); } updateStatus() { const online = navigator.onLine; this.innerHTML = ` <div class="status ${online ? 'online' : 'offline'}"> ${online ? 'โœ“ Online' : 'โœ— Offline'} </div> `; } } customElements.define('network-status', NetworkStatus);

Live Demos

A clock, a resize tracker, and a network status indicator all run in the frame above. The three samples here are teaching sketches of the same three patterns, not the demo's code: the real file formats its clock differently, puts the resize tracker in a shadow root, and โ€” for the network status โ€” uses the single AbortController shown below rather than the paired removeEventListener calls above. Open the frame's view-source button to compare.

Memory Leak Prevention

Memory leaks occur when components hold references that prevent garbage collection. Always clean up resources in disconnectedCallback.

Common Memory Leak Sources

  • Event listeners on window/document - Use removeEventListener
  • Observers (Mutation, Intersection, Resize) - Call disconnect()
  • Timers (setTimeout, setInterval) - Use clearTimeout or clearInterval
  • External references - Set to null or use WeakMap

Cleanup Checklist Pattern

class WellBehavedElement extends HTMLElement { connectedCallback() { // Set up resources this._interval = setInterval(() => this.update(), 1000); this._observer = new MutationObserver(() => this.handleMutation()); this._observer.observe(document.body, { childList: true }); window.addEventListener('resize', this._handleResize); document.addEventListener('click', this._handleClick); } disconnectedCallback() { // Clean up timers if (this._interval) { clearInterval(this._interval); this._interval = null; } // Disconnect observers if (this._observer) { this._observer.disconnect(); this._observer = null; } // Remove event listeners window.removeEventListener('resize', this._handleResize); document.removeEventListener('click', this._handleClick); // Clear data references this._data = null; this._cache = null; } }

One signal, every listener

The classic component leak is a listener registered on window or document in connectedCallback and never removed: the element goes away, the listener does not, and it holds a reference to the element forever. Removing them one by one means keeping a matching bound reference for each. An AbortController collapses all of that into one signal.

class ViewportWatcher extends HTMLElement { #controller; // Arrow-function class fields are bound to the instance for free โ€” no // bind() in the constructor, and the same reference on every connect. #onResize = () => this.#update(); #onVisibility = () => this.#update(); connectedCallback() { // A fresh controller each time: an element can reconnect. this.#controller = new AbortController(); const { signal } = this.#controller; window.addEventListener('resize', this.#onResize, { signal }); document.addEventListener('visibilitychange', this.#onVisibility, { signal }); this.#update(); } disconnectedCallback() { this.#controller.abort(); // removes every listener above at once } #update() { this.textContent = `${window.innerWidth}px ยท ${document.visibilityState}`; } } customElements.define('viewport-watcher', ViewportWatcher);

Advanced Lifecycle Patterns

Pattern 1: Lazy Initialization

Defer expensive setup until the element is actually connected to the DOM:

class LazyComponent extends HTMLElement { connectedCallback() { // Only create template once if (!this._template) { this._template = this.createExpensiveTemplate(); } // Use the cached template this.appendChild(this._template.cloneNode(true)); } createExpensiveTemplate() { const template = document.createElement('template'); // ... expensive DOM creation return template.content; } }

Pattern 2: Handling Reconnection

Components can be removed and re-added. Handle this gracefully:

class ReconnectableComponent extends HTMLElement { connectedCallback() { // Do expensive setup only once if (!this._initialized) { this.expensiveSetup(); this._initialized = true; } // Do this every time we connect this.start(); } disconnectedCallback() { // Stop but don't destroy state this.stop(); // Don't reset _initialized } expensiveSetup() { // Heavy lifting (load data, create structures, etc.) } start() { // Start timers, observers, etc. } stop() { // Stop timers, observers, etc. } }

Pattern 3: Debouncing Attribute Changes

Avoid expensive re-renders when attributes change rapidly:

class DebouncedComponent extends HTMLElement { static get observedAttributes() { return ['value', 'size', 'theme']; } attributeChangedCallback(name, oldValue, newValue) { // Clear previous timeout if (this._updateTimeout) { clearTimeout(this._updateTimeout); } // Batch updates this._pendingChanges = this._pendingChanges || {}; this._pendingChanges[name] = newValue; // Debounce the actual update this._updateTimeout = setTimeout(() => { this.update(this._pendingChanges); this._pendingChanges = {}; }, 100); } update(changes) { // Process all changes at once console.log('Updating with:', changes); this.render(); } }

Demo 2: Attribute Change Handling

Explore how components respond to attribute changes throughout their lifecycle. Try changing different attributes to see how the component updates.

Live Demo

Best Practices

Common Pitfalls

  • โŒ Common Mistakes

    class BadElement extends HTMLElement { constructor() { super(); // โŒ DOM manipulation in constructor this.innerHTML = '<div>Bad!</div>'; // โŒ Reading attributes this.value = this.getAttribute('value'); } connectedCallback() { // โŒ Forgetting to bind window.addEventListener('resize', this.handleResize); } disconnectedCallback() { // โŒ No cleanup - memory leak! } attributeChangedCallback(name, old, val) { // โŒ Infinite loop this.setAttribute(name, val.toUpperCase()); } }
  • โœ… Correct Approach

    class GoodElement extends HTMLElement { constructor() { super(); // โœ… Only initialize state this._value = 0; // โœ… Bind methods this.handleResize = this.handleResize.bind(this); } connectedCallback() { // โœ… DOM work in connectedCallback this.render(); // โœ… Store listener reference window.addEventListener('resize', this.handleResize); } disconnectedCallback() { // โœ… Clean up properly window.removeEventListener('resize', this.handleResize); } attributeChangedCallback(name, old, val) { // โœ… Update internal state, don't set attribute if (old !== val) { this._value = val; this.render(); } } }

Next Steps

In Step 6: Slots and templates, we'll explore:

  • <template> as inert markup, and why you clone it rather than move it
  • Named slots, the default slot, and fallback content
  • Reacting to assignment changes with slotchange
  • Composition versus configuration โ€” when an input is a slot and when it is an attribute

Step 06

Slots and templates

Steps 02 through 05 built an element that owns everything inside it. That is fine until somebody wants to put their markup in it โ€” a link in the header, an icon next to the title, another one of your components in the footer. Attributes cannot carry markup, so the answer is not another attribute. It is a hole in the component that the consumer fills. This step covers the two pieces that make that work: <template>, which lets you keep inert markup in the document and stamp copies of it, and <slot>, which decides where the consumer's own nodes render. Then the design question the two of them raise: when is something an attribute, and when is it a slot?

Learning Objectives

  • Use <template> to hold inert markup and stamp clones of it
  • Distinguish named slots from the default slot, and use fallback content
  • React to assignment changes with slotchange
  • Tell composition from configuration when designing an element's API

<template> is inert markup

A <template> element is parsed like any other markup โ€” the parser reads it, validates it, and builds real nodes โ€” but it is never rendered. Its children do not go into the document tree at all. They go into a separate DocumentFragment hanging off the element's .content property, which lives outside the main tree.

That "outside the main tree" part is the whole feature, and it is more than a display trick. Scripts inside a template do not run. Images inside it do not load. Custom elements inside it are not upgraded. Nothing in there costs anything until you clone it into a tree that is actually being rendered. A <div hidden> full of markup does none of that: the browser still fetches its images and still runs its custom element definitions, it just refuses to paint the result.

<template id="card-template"> <article> <header><slot name="title">Untitled</slot></header> <slot></slot> </article> </template>

To use it, clone the fragment. Always cloneNode(true) โ€” never append .content itself. Appending a DocumentFragment moves its children out of it and leaves the fragment empty, so the first instance of your component would render and every instance after it would get nothing. Cloning keeps the template reusable, which is the reason you put the markup in a template in the first place.

const template = document.getElementById('card-template'); class InfoCard extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; // root already built โ€” do not rebuild this.attachShadow({ mode: 'open' }) .append(template.content.cloneNode(true)); } }

The template does not have to come from the document. The demo later in this step builds one with document.createElement('template') and a string of markup, which is the usual shape when a component ships as a single JavaScript module and cannot assume the page contains anything in particular. Either way, the parse happens once and every instance clones the result.

Slots project, they do not move

This is the sentence to hold on to: a <slot> changes where a node renders, not where it lives. When you write markup inside a custom element that has a shadow root, those nodes stay exactly where you put them โ€” in the light DOM, as children of the host element. The shadow tree declares a spot, and the browser paints them there. Nothing is moved, reparented, or copied.

Every consequence follows from that. card.children still lists what you wrote. card.querySelector('p') still finds the paragraph. Page-level CSS still styles it, because it is still an ordinary element in the ordinary document โ€” which is exactly why step 04's ::slotted() exists at all. If slotted nodes really moved into the shadow tree, the page would have lost the ability to style them and there would be nothing for ::slotted() to do.

const card = document.querySelector('info-card'); card.children.length; // 2 โ€” the span and the p card.querySelector('p').parentElement === card; // true // The shadow tree only decided *where* those nodes render. card.shadowRoot.querySelector('slot:not([name])').assignedElements(); // [ <p> ]

Named and default slots, and fallback

A shadow tree may contain one unnamed <slot>, and that one is the default: any child of the host without a slot attribute is assigned to it. Give a slot a name and a child opts in by matching it with slot="name". Children whose slot value matches no slot in the tree are assigned nowhere and simply do not render.

<info-card> <span slot="title">Ada Lovelace</span> <p>First published algorithm intended for a machine.</p> </info-card>

The <span> goes to slot="title"; the <p> has no slot attribute, so it lands in the default slot. Note that the order of the children in the markup does not have to match the order of the slots in the shadow tree โ€” assignment is by name, and the shadow tree decides layout.

Anything you write between a slot's tags is fallback content. It renders only while nothing is assigned to that slot, and disappears the moment something is. In the template above, Untitled shows until the consumer supplies a title. Fallback is the cheapest way to make a component usable with no configuration at all, and it costs one line.

Reacting to slotchange

Slot assignment is live. A consumer can append, remove, or re-slot a child at any time, and the shadow tree has to keep up. The slotchange event fires on a slot when its set of assigned nodes changes, including the very first assignment when the element is first populated โ€” so a listener attached in connectedCallback will hear about the initial content, not just later edits.

Two accessors read what a slot currently has. assignedElements() returns just the elements, which is what you want most of the time. assignedNodes() returns nodes, including text nodes and whitespace. Both take the same { flatten: true } option, and it does two things: it resolves nested slots โ€” if the assigned content is itself a slot in another component's tree, flatten follows it to the real nodes โ€” and, when nothing is assigned at all, it returns the slot's fallback content instead of an empty array. That second behavior is the one that surprises people, so keep it out of any check that is really asking "is this slot filled?".

const slot = this.shadowRoot.querySelector('slot[name="actions"]'); slot.addEventListener('slotchange', () => { const count = slot.assignedElements().length; this.toggleAttribute('has-actions', count > 0); });

That toggled attribute is the useful trick. The component cannot style an empty footer out of existence with CSS alone, but it can reflect "somebody gave me actions" onto the host as an attribute, and then :host(:not([has-actions])) footer { display: none; } inside the shadow tree does the rest. A slot's occupancy becomes a styling hook.

Demo: slot composition

The card below has a named title slot, a default slot for its body, and a named actions slot whose footer is hidden until something is assigned to it. Each button changes the light DOM โ€” appending a button with slot="actions", removing them again, appending a plain paragraph โ€” and every one of those edits produces a slotchange entry in the log. Watch which slot fires: adding a paragraph does not disturb the actions slot, and adding an action does not disturb the default one.

Composition versus configuration

Every component API is a series of decisions about which of these two an input is. Get it wrong in the configuration direction and consumers fight you with attribute values that want to be markup. Get it wrong in the composition direction and a component that should have taken a string demands three lines of boilerplate.

  • โš™๏ธ Attributes configure

    An attribute carries a scalar โ€” a string, or a string parsed into a number or boolean. It is part of the element's declared API surface: you can observe it, reflect it, validate it, and match it in CSS with :host([variant="danger"]). The component decides what the value means.

    <info-card variant="danger" label="Save"> </info-card>
  • ๐Ÿงฉ Slots compose

    A slot carries arbitrary markup that the consumer owns โ€” text, elements, links, icons, even other components. The component never inspects it or decides what it means; it only decides where it goes. Nothing about it is reflectable, because it is not a value.

    <info-card> <span slot="label">Save <kbd>โŒ˜S</kbd></span> </info-card>

The two are not exclusive, and the best components use both on the same concept: a named slot for the rich case, with fallback content that renders an attribute for the simple one. Consumers who want a plain string set the attribute and never think about slots; consumers who want markup fill the slot and the fallback vanishes on its own.

Next Steps

In Step 7: Styling and theming, we'll explore:

  • Designing a theming surface consumers can rely on
  • Dark mode across the shadow boundary
  • adoptedStyleSheets and sharing one stylesheet across every instance
  • Why ::part is a versioning hazard

Step 07

Styling and theming

Step 04 showed the three ways style crosses the shadow boundary: ::part, ::slotted, and custom properties. This step is not about those mechanisms again. It is about the design problem they leave you with. A consumer cannot reach into your shadow root, so the only styling they will ever be able to do is the styling you decided in advance to allow โ€” which makes your set of custom properties a public API with all the obligations that word carries. This step is about choosing that set deliberately: naming it, defaulting it, shipping variants alongside it, letting one switch at the root theme every component on the page, and serving all of it from a single stylesheet no matter how many instances exist.

Learning Objectives

  • Design a custom-property surface and treat it as a stable API
  • Ship component variants with :host()
  • Theme across the shadow boundary, dark mode included
  • Share one stylesheet across every instance with adoptedStyleSheets
  • Recognize when ::part is a versioning hazard

A theming surface is an API

Encapsulation makes the theming question unusually stark. With an ordinary <div class="card">, a consumer who wants a different border can write a selector and get one, whether you anticipated it or not. With a shadow root they cannot. There is no selector they can write, no specificity they can escalate to, no !important that helps. Whatever you did not expose, they cannot change.

The other half is the part people forget. Whatever you did expose, you have promised to keep. The moment a consumer writes --card-bg: #101828 in their stylesheet, that property name is load-bearing in somebody else's code. Renaming it, or quietly ceasing to read it, breaks their page in a way no compiler will catch โ€” the page just renders wrong. A theming surface is not a convenience you sprinkle on at the end; it is versioned API, and it deserves the same care as a method signature.

:host { background: var(--card-bg, #ffffff); color: var(--card-fg, #1f2937); border-radius: var(--card-radius, 8px); }

Two rules follow from that, and both are visible in those three lines. Name properties after the component. Custom properties inherit, and they live in one flat namespace shared by the entire document. A component that reads --bg will happily pick up a --bg some unrelated widget set on <body> for its own purposes, and the two of them will fight over a name neither one owns. --card-bg cannot collide with --dialog-bg. Prefix everything.

Always supply a fallback. The second argument to var() is what renders when the page sets nothing, and a component whose defaults are all var(--card-bg) with no fallback renders as unstyled wreckage on any page that has not opted in. The fallback is what makes the component usable before anybody reads your documentation โ€” and it doubles as documentation, since a reader can see the intended value right next to the property name.

Custom properties inherit through the boundary; ordinary rules do not

Everything above rests on one asymmetry in how the boundary works, and it is worth stating plainly because it looks like an inconsistency until you see why it is not.

Selector matching stops at the boundary. A page-level rule names elements the page can see, and the nodes inside a shadow root are not among them, so the rule simply never matches. Inheritance does not stop at the boundary. Inherited values โ€” color, font-family, and every custom property โ€” flow down the flattened tree, and the flattened tree runs straight through shadow roots. A custom property set on :root is therefore in scope on every node of every shadow tree on the page, and var() inside the component reads it exactly as it would read any other inherited value.

/* Page stylesheet */ /* Never matches: .card is a node inside the shadow tree. */ .card { background: red; } /* Reaches every themed-card on the page: custom properties inherit. */ :root { --card-bg: red; }

So the component author is not fighting the boundary when they design a property surface โ€” they are using the one channel that was always open. Step 04 walks the full set of crossing points, ::part and ::slotted included; see step 04's styling strategies for the mechanics. What matters here is that of the three, this is the one that scales: it needs no coordination about node names, it works on any number of components at once, and it is the same mechanism a page already uses to theme itself.

Variants with :host()

Not every styling decision belongs to the consumer. Some belong to the component: a card has a warning presentation, a button has a danger presentation, and the component author is the one who knows what those should look like. That is what :host() is for. It matches the host element from inside the shadow tree, conditioned on a selector the host matches, so the component can ship a named variant and the consumer opts in with one attribute.

:host([variant="warning"]) { --card-bg: #fef3c7; --card-border: #f59e0b; } :host([hidden]) { display: none; }

Notice what the first rule sets: not background, but the property the base rule already reads. A variant that overrides the custom properties rather than the declarations stays composable โ€” the base :host rule remains the single place where background is decided, and a consumer who sets --card-bg on the warning card from the page still wins, because for normal declarations the outer tree's rules take precedence over the shadow tree's. Set background outright in the variant and you have quietly made that card untuneable.

Where variants come from matters more than it looks. An attribute the component defines and matches with :host([variant]) is a declared part of its API, the same way step 03 treated any other attribute: enumerable, documentable, and matched identically in every browser.

There is a tempting alternative that you should not take. :host-context() matches on an ancestor of the host, which sounds like the natural way to pick a theme up from a wrapper โ€” but it is Chromium-only, with no cross-browser equivalent, and step 04's compatibility warning covers what to do instead.

Dark mode across the boundary

Dark mode is where the design pays off, and it is the argument for property-based theming in one screenshot. Because custom properties inherit, the page does not have to visit each component, or know which components exist, or care whether they use shadow DOM at all. It redeclares the properties once at the root and every consumer of them updates โ€” including components that were written years earlier by somebody who never considered dark mode.

:root { --card-bg: #ffffff; --card-fg: #1f2937; } @media (prefers-color-scheme: dark) { :root { --card-bg: #1f2937; --card-fg: #f9fafb; } } /* An explicit toggle must win over the media query */ :root[data-mode="light"] { --card-bg: #ffffff; --card-fg: #1f2937; } :root[data-mode="dark"] { --card-bg: #1f2937; --card-fg: #f9fafb; }

Both forms are in there because a real site needs both. The media query reads the operating system's preference, which is the right default and requires no interaction. But users override their system preference all the time โ€” a dark desktop and one site they want light, a bright room, a document they are about to print โ€” and a media query alone gives them no way to say so. The explicit toggle is what turns a system preference into a user choice.

The two have to be ordered so the toggle wins. The [data-mode] rules come last and carry an attribute selector, so they outrank the plain :root inside the media query on both count and order; the media query changes nothing about specificity by itself. Setting data-mode on <html> is then one line of JavaScript, and it is worth persisting the choice so the next page load does not undo it.

The demo below deliberately does not do this. It carries the explicit toggle and no media query at all, so the buttons stay the single variable while you read the theming surface โ€” an operating system set to dark changes nothing in that frame. That is a simplification made for a teaching demo, and it is exactly the simplification not to carry into real work.

Sharing one stylesheet with adoptedStyleSheets

Everything so far has been about which values cross the boundary. This is about the rules themselves. Style scoping is per-root, so every shadow root needs the component's CSS inside it โ€” and if you supply that CSS as a <style> element in a cloned template, each instance gets its own stylesheet object with its own rule objects. A hundred cards means a hundred of them. (Engines do cache the parsed result of identical style text, so the re-parsing is usually not where you lose; the duplicated sheets are.)

A constructed CSSStyleSheet gives you one instead. You build it once at module scope, fill it with replaceSync(), and hand it to as many shadow roots as you like. One sheet object, shared โ€” not copied โ€” by every root that adopts it.

const sheet = new CSSStyleSheet(); sheet.replaceSync(':host { display: block; }'); class ThemedCard extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; const root = this.attachShadow({ mode: 'open' }); root.adoptedStyleSheets = [sheet]; // shared, not copied } }

"Shared" is meant literally, and it is the second reason to reach for this. Mutating the sheet later โ€” through replaceSync() or the CSSOM โ€” updates every shadow root that adopted it, in one operation, with no traversal of the document. The property holds a list rather than a single sheet, so a root can adopt several at once: a shared design-system sheet plus a small component-specific one is a common and sensible shape. It is an ObservableArray rather than a plain one, so assign a new array to it rather than pushing into the old.

Demo: one component, three themes

Two themed-card elements, one component definition, one shared stylesheet. Every color in both cards comes from a custom property, and the three theme buttons do nothing but set data-theme on <html> โ€” no code in the page touches a shadow root. Watch the second card as you cycle themes: its :host([variant="warning"]) rule declares --card-bg on the host itself, which outranks the value inherited from :root, so its amber holds under every theme while the first card follows the page.

Notice that the variant declares --card-fg too, and that this is not decoration. A variant that overrides some of the properties a component reads and lets the rest keep inheriting has left an incomplete surface, and the gap stays invisible until somebody applies a theme nobody tested against it. Fix a background and leave the foreground inheriting, and the first dark theme to come along hands that card near-white text to paint on its own light background โ€” a card that renders as an empty box. Colors that depend on each other have to be overridden together. The page also carries a loud header { color: #dc2626 } rule that never matches anything, because each card's header is on the far side of the boundary.

::part is a narrower promise

Step 04 covers what ::part does and how to write it. The design question is when to reach for it, and the answer turns on what each of the two mechanisms actually names.

  • ๐ŸŽ›๏ธ A custom property names a value

    --card-bg is a slot in your component's configuration. What reads it, how many rules use it, and what markup sits underneath are entirely your business. You can rewrite the shadow tree from scratch and the property keeps working, as long as something still paints with it.

  • ๐Ÿงท A part names a node

    part="header" exposes a specific element in a specific structure. Consumers write rules against it that assume it is that kind of element, in that position, with those box-model properties. Rename it, or restructure around it, and every one of those rules silently stops matching.

So the two are not interchangeable tools with different syntax; they are promises of very different width. A property commits you to reading a name. A part commits you to keeping a piece of your internal structure โ€” which is exactly the thing encapsulation was supposed to let you change freely.

Prefer properties for values, which is most of what consumers want. Reserve ::part for hooks that are genuinely structural and that no property can express: a consumer who needs to reposition your close button, or give one internal element a layout of their own, has no property-shaped way to ask. When you do expose a part, name it for its role rather than its markup โ€” part="dismiss" survives the day the <button> becomes an <a>; part="close-button" does not.

Next Steps

In Step 8: Form-associated elements, we'll explore:

  • static formAssociated and what ElementInternals hands you
  • Taking part in real form submission, so a custom element's value reaches the server
  • Validity states and validation messages
  • The form lifecycle callbacks: reset, restore, and disabled state from a fieldset

Step 08

Form-associated elements

Every component this series has built so far is self-contained: it renders, it reacts, and nothing outside it needs to know what is inside. A form control is the opposite, because the browser collects it: it has a name and a value the form gathers on submit, a validity state that can stop that submit, and a set of behaviors โ€” reset, disable, restore โ€” the form drives rather than your code. A custom element gets none of that by default, and putting an <input> inside it does not help, because a control in your shadow tree has no form owner at all. This step covers the opt-in that fixes it: static formAssociated and the form half of ElementInternals, which together let the browser treat your element as one of its own controls โ€” and the parts of being a control that no API hands you.

Learning Objectives

  • Make a custom element the form actually submits
  • Report a validity the browser enforces, not one you police yourself
  • Respond to reset, disable, and state restoration
  • Know what ElementInternals gives you โ€” and what it does not

A custom element is not a form control

Start with what actually happens, because it is less dramatic than a bug and easier to miss. Put <my-field name="email"> inside a <form>, give it a beautiful editing experience, and mark it required. Nothing errors. The form does not complain. It also does not collect anything: the element is not on the form's list of controls, so FormData has no entry for it, the required attribute is an attribute nobody reads, and pressing Submit with the field untouched submits the form happily, with the field simply absent.

// <form id="signup"> // <my-field name="email" required></my-field> // </form> const form = document.getElementById('signup'); new FormData(form).get('email'); // null โ€” the element contributes nothing form.elements.email; // undefined โ€” it is not a control form.checkValidity(); // true โ€” there is nothing to be invalid

Nesting a real control inside does not rescue it either. A form control's owner is found in its own tree, so an <input> in your shadow root has no form owner โ€” not the outer form, not any form โ€” and is never submitted with it. That is not an oversight; it is encapsulation doing exactly what step 04 said it does. The boundary that keeps the page out of your component keeps your component out of the page's form.

Before form association existed, the workaround was a hidden <input> in the light DOM shadowing the real UI, written to by hand on every change. It submits, which is the only thing it does well. Reset clears the hidden input and leaves your visible UI showing the old value. A disabled ancestor <fieldset> greys out nothing, because there is nothing of yours for it to reach. Validation either points at an invisible element or is reimplemented from scratch, with your own bubble, your own submit interception, and your own bug the day somebody submits by pressing Enter. Every one of those is a symptom of the same thing: the browser does not know your element exists. The rest of this step is about telling it.

Opting in

Two things, and both are required. A static field on the class declares that instances of this element are form controls, and attachInternals() returns the object through which the element talks to the form.

class EmailField extends HTMLElement { static formAssociated = true; // must be a static field on the class #internals = this.attachInternals(); // one per element, ever }

formAssociated is read once, when customElements.define() runs, and it is read off the constructor โ€” so it has to be a static field or a static getter, not something you assign to an instance later. Setting it changes the element itself, before you write another line: it becomes labelable, so <label for> associates with it; it supports disabled; it appears in form.elements; and it inherits the disabled state of an ancestor <fieldset>.

attachInternals() is the same call step 03 used for custom states, and the same object โ€” there is only ever one per element. What formAssociated unlocks is its form half: form, setFormValue(), setValidity(), validity, labels, and the rest.

Submitting a value

One call does it. setFormValue() tells the form what this control's value currently is, and from that moment the element has an entry in FormData and in the submitted body like any native control.

#sync() { this.#internals.setFormValue(this.#input.value); }

Two things about that line matter more than its length. The first is that the browser never asks: it stores whatever you last handed it, so #sync() has to run on every change that alters the value โ€” input events, programmatic property sets, anything. Miss one and the form submits a stale value with no warning, because from the form's point of view nothing is wrong. The second is that the element still needs a name attribute, exactly like a native control. No name, no entry; the value is set and simply never submitted.

The argument does not have to be a string. Pass a File for an upload control, or null to contribute nothing at all โ€” the way an unchecked checkbox contributes nothing. Pass a FormData when one control owns several entries: a date-range picker that submits start and end, or a card field that submits a number and an expiry. Those entries carry their own names, taken from the FormData rather than from the element's name. There is also an optional second argument, a state value, which the browser keeps separately and hands back to formStateRestoreCallback() โ€” useful when what you need to restore your UI is not the same as what you submit.

Validity the browser respects

This is the part that is genuinely hard to fake, and the reason form association is worth the ceremony. setValidity() does not record a note for your own code to check later; it puts the element into the browser's constraint validation machinery, next to every <input required> on the page.

Note what it does not do, because this is where the required attribute from the opening section finally gets its answer. Form association never teaches the browser what your attributes mean. required is still an attribute nobody reads on your behalf โ€” the sample below reads it with hasAttribute() and decides for itself โ€” and the same goes for pattern, minlength, and every other constraint name you choose to honor. What you get from the platform is the machinery that acts on the verdict, not the rule that reaches it.

if (this.hasAttribute('required') && value === '') { this.#internals.setValidity({ valueMissing: true }, 'An email address is required.', this.#input); } else { this.#internals.setValidity({}); }

The three arguments each do one job. The first is a set of validity flags โ€” the same names a native control reports: valueMissing, typeMismatch, patternMismatch, rangeOverflow, customError, and the rest. Any flag you leave out is false, so setValidity({}) is how you say "valid" and is the branch people forget: a control that only ever sets flags never becomes valid again. The second is the message the browser will display, and it is required whenever any flag is true โ€” passing flags without a message is a TypeError, not a silently empty bubble. The third is an anchor: an element inside your shadow tree that the browser scrolls to and points the bubble at. Omit it and submission is still blocked, but the browser has no node inside your component to aim at.

What you get in return is everything constraint validation already does, without writing any of it. Submission stops before the submit event fires, so there is nothing to intercept and no handler to remember to guard. :invalid and :valid match your host element in CSS. form.checkValidity() counts your element in, and form.reportValidity() shows your message.

One piece of that surface is not free, and it catches people who assume the element is now a control in every respect. The validity state is real, but the validity properties are not there: element.validity, element.validationMessage, element.willValidate, and element.form are all undefined on a form-associated custom element unless you put them there. They live on ElementInternals, and ElementInternals is deliberately private โ€” the point of putting the form API on a separate object is that the page cannot reach it. Forwarding the ones you want is a handful of one-line getters, and worth writing, because that is what makes your element inspectable by code that expects a native control.

get form() { return this.#internals.form; } get validity() { return this.#internals.validity; } get validationMessage() { return this.#internals.validationMessage; }

One consequence worth knowing before it surprises you: a disabled control is barred from constraint validation entirely. An element that is disabled, or sits inside a disabled <fieldset>, has internals.willValidate read false, submits no value, and cannot block the form no matter what flags are set on it. That is native behavior, and now it is yours too.

The form lifecycle callbacks

Being a control is not only about submission. Forms do things to their controls, and a form-associated custom element gets told about each of them through four callbacks that sit alongside the lifecycle set from step 05.

  • formAssociatedCallback(form) โ€” the element joined a form, or left one (null)
  • formResetCallback() โ€” the form was reset; clearing yourself is your job
  • formDisabledCallback(disabled) โ€” the element or an ancestor <fieldset> was disabled or re-enabled
  • formStateRestoreCallback(state, mode) โ€” the browser is restoring a value on back-navigation ("restore") or autofill ("autocomplete")
formResetCallback() { this.#input.value = ''; this.#sync(); } formDisabledCallback(disabled) { this.#input.disabled = disabled; }

formResetCallback() is a notification, not a reset. The browser restores its own controls to their default values and then tells you it happened; if your callback does nothing, the form clears every native field around you and your element sits there still showing the old value. Note the second line, too โ€” clearing the visible input does not clear the value the form holds, so the callback has to run the same #sync() as any other change. That is the argument for putting value and validity in one function: there is only one place that can get out of step, and everything calls it.

formDisabledCallback() is the one that would be genuinely painful to do yourself. Disabled state is inherited from any ancestor <fieldset>, at any depth, and it changes whenever that fieldset does โ€” there is no attribute on your element to observe and no event to listen for. The browser walks it for you and calls the method.

Demo: a custom element inside a real form

One <email-field> in an ordinary <form>, with an ordinary submit button. Press Submit with the field empty and watch what happens: the browser's own validation bubble appears, and the message in it is the string passed to setValidity(). The submit handler below never runs โ€” the output block still reads "(nothing submitted yet)". Nothing in the page checked anything.

Then type a valid address and submit: the output shows an "email" entry holding what you typed, read straight out of new FormData(form), which is the proof that the element is a real entry rather than something the page copied across by hand. Reset clears it, through formResetCallback(). The red border while the field is invalid comes from :host(:state(invalid)) โ€” step 03's custom states, not a class the page can set.

The last button is the one to watch closely. It does not call formDisabledCallback(); it toggles disabled on the ancestor <fieldset> and nothing else. The inner input greys out anyway, because the browser propagated the state and invoked the callback. Submit while it is disabled and the form goes through with no email entry at all โ€” the barred-from-validation rule from the previous section, visible.

What you still owe the user

Form association makes the element a control to the form. It does not make it a control to the person using it, and the gap is easy to ship without noticing, because the demo above submits correctly while still being worse than an <input> in several ways.

Focus does not route itself. The host is labelable, so <label for> associates and internals.labels lists the labels โ€” but clicking that label calls focus() on your host, and a host that is not focusable passes nothing inward, so the focus lands nowhere. The click itself is not lost โ€” label activation still dispatches a click event at the host, so a listener there fires exactly as you would expect. It is the caret that never arrives, which is the half users notice and the half tests do not. Tabbing still reaches the control, because sequential focus navigation walks into open shadow roots on its own; it is every deliberate focus that lands nowhere, including your own element.focus(). Pass delegatesFocus: true when you attach the shadow root, or manage tabindex and override focus() yourself.

// Focus has to be routed deliberately const root = this.attachShadow({ mode: 'open', delegatesFocus: true }); // A default the page can still override with a real ARIA attribute this.#internals.ariaLabel = 'Email address';

The accessible name is not automatic either. internals.ariaLabel, internals.role and their siblings set the element's defaults in the accessibility tree โ€” deliberately, so that an author who writes aria-label on your element in their markup still wins. That is the right precedence, and it also means your defaults are only defaults: set none and there is no name to fall back to. The demo above has the bug on purpose. Its <label for> associates with the host, but the node a screen reader actually lands on is the <input> inside the shadow root, and nothing ever named that input โ€” so it is announced by its placeholder. Association is not naming.

Keyboard behavior is entirely yours. Native controls carry conventions the browser implements: arrow keys move between radio buttons, Escape closes a picker, Enter in a text field submits the form. A form-associated custom element inherits none of them โ€” the inner input has no form owner, so Enter does not submit โ€” and every one you want, you write and test yourself.

Next Steps

In Step 9: Declarative shadow DOM, we'll explore:

  • <template shadowrootmode>: a shadow root that arrives from the parser, with no JavaScript at all
  • Hydration as adoption rather than reconstruction โ€” upgrading against a root that already exists
  • Why innerHTML will not parse a declarative shadow root, and what setHTMLUnsafe() is warning you about
  • Serializing a shadow root back to HTML with getHTML()
  • What all of it means for a static site generator like the one serving this page

Step 09

Declarative shadow DOM

Eight steps of components, and all of them share a dependency nobody has said out loud: none of it exists until JavaScript runs. The parser meets <user-card>, finds no definition for the name, and renders an empty inline element; everything that makes it a card is created afterwards, by a script, inside a callback. Declarative shadow DOM removes that dependency for the markup half. A <template> with one attribute is consumed by the parser and becomes the host's shadow root โ€” styled, slotted, and painted before a byte of your JavaScript has been fetched. This step covers that attribute, the one-line guard that makes the later upgrade an adoption rather than a rebuild, the API that refuses to parse a declarative shadow root and the one that will, and what all of it means for a page built the way this one is.

Learning Objectives

  • Render a shadow root from markup, with no JavaScript at all
  • Adopt a parser-delivered root instead of rebuilding it
  • Know which APIs parse a declarative shadow root โ€” and which deliberately do not
  • Serialize a shadow root back out to HTML
  • Say what all of it does and does not solve for a statically generated site

The component that is not there yet

Follow what the browser actually does with <user-card> on a first load. The parser reaches the tag, sees a name it does not recognize, and creates an element for it โ€” an HTMLElement with no behavior, no shadow root, and, unless the page styled it, display: inline. It has children, because you wrote them, but nothing arranges them. Then the page paints. What the user sees at that moment is whatever your light DOM happened to be: a stack of unstyled spans, or, in the common case where the component supplies all of its own markup, nothing at all.

Some time later the module arrives, define() runs, every matching element upgrades, and connectedCallback builds the shadow tree. The card appears. The gap between those two moments is the flash: a blank or misshapen region that reflows into a component once the network and the main thread have both cooperated. On a fast connection it is a flicker. On a slow one it is the page. And if the script fails to parse, or 404s, or is blocked by an extension, the gap is permanent โ€” there is no state in which the component renders and no error the user can act on.

That is a strange place for this series to have ended up, because step 01 started from the opposite premise. Markup first, behavior after: a <product-card> that meant something before any script existed, and worked with HTML and CSS alone. Every step since has added capability, and each one quietly moved more of the component's substance out of the document and into a callback. Declarative shadow DOM is how you get the first half back without giving up the second.

<template shadowrootmode>

The whole feature is one attribute. A <template> that carries shadowrootmode and sits as a direct child of some element is not treated as a template at all: the HTML parser consumes it, attaches a shadow root to that parent, moves the template's contents into the root, and removes the template from the tree. By the time the document is parsed there is no <template> left to find โ€” there is a host with a shadow root, exactly as if attachShadow() had been called and filled.

<user-card> <template shadowrootmode="open"> <style>:host { display: block; }</style> <slot name="name">Anonymous</slot> </template> <span slot="name">Ada Lovelace</span> </user-card>

Read that markup with the previous eight steps in mind and notice how much of the model is already present with no JavaScript anywhere. The <style> is scoped to the root, so :host works and the rules cannot leak. The <slot> projects the light-DOM <span> into place, fallback content and all, by exactly the mechanism step 06 described. What is missing is only the behavior: there is no class, no registered name, nothing listening. The boundary and the markup arrived without them.

Four attributes in all: the one that triggers the behavior, and three that set the options attachShadow() would otherwise take. They have to be spelled on the template because there is no call to pass them to.

  • shadowrootmode โ€” "open" or "closed"; required, and the reason the parser treats the template specially at all
  • shadowrootdelegatesfocus โ€” the delegatesFocus: true that step 08 needed to route a label click inward
  • shadowrootclonable โ€” the root comes along when the host is cloned with cloneNode(true)
  • shadowrootserializable โ€” the root may be read back out as markup; see the serialization section below

A closed declarative root is worth a moment's thought, because the markup is right there in the document for anyone to read โ€” which is the clearest demonstration yet of step 04's point that mode: "closed" is an encapsulation choice and not a security one. It also breaks the guard in the next section, and not gently: this.shadowRoot is null for a closed root, so the check concludes there is nothing there and calls attachShadow({ mode: 'open' }) โ€” against a root whose mode is closed. That mismatch throws NotSupportedError: The requested mode does not match the existing declarative shadow root's mode, uncaught, taking the rest of connectedCallback down with it. The element ends up with the parser's markup and none of its behavior. The element can still find its own root through attachInternals().shadowRoot โ€” the same ElementInternals object step 08 used, which reports the root whatever its mode โ€” but that is one more thing to remember for a mode you probably did not need.

Hydration is adoption, not reconstruction

Eventually the definition loads and the element upgrades. The constructor runs, then connectedCallback โ€” and it runs against an element that already has a shadow root, already populated, already painted. Every component in this series so far has begun that callback by creating one.

Do that here and you have written the bug this whole step exists to avoid โ€” and nothing will stop you. attachShadow() normally throws a NotSupportedError on an element that already has a root, but a root the parser delivered is a deliberate exception: the call succeeds, empties that root, and hands it back. The exception exists so that components written before any of this shipped do not break on a server-rendered page. What it costs you is the warning. The markup the server sent is discarded, rebuilt from a string, and the flash you paid bytes to avoid comes back โ€” with nothing in the console to say so. The guard is one line.

Two conditions on that exception are worth knowing before you rely on it. The mode has to match: asking for { mode: 'open' } against a template that declared shadowrootmode="closed" throws NotSupportedError rather than adopting, which is the closed-root trap from the previous section. And it is one-shot โ€” the first call converts the parser's root into an ordinary shadow root, so a second attachShadow() on the same element throws NotSupportedError: Shadow root cannot be created on a host which already hosts a shadow tree, exactly as it would anywhere else. The exception is a migration ramp for old code, not a mode you can build on.

connectedCallback() { if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }).innerHTML = TEMPLATE; // runtime path only } this.#wire(); // listeners only โ€” the markup already exists }

That shape is worth reading as a claim about responsibility rather than as a null check. The markup is no longer the callback's job in the common case; the callback's job is behavior. It queries nodes that are already there, attaches listeners to them, and sets whatever state the static markup could not express. Everything above the guard is a fallback for the one case the server did not cover.

Which means the callback has to be written so that both entries land in the same place. A component whose declarative markup and whose runtime template have drifted apart renders one way on first load and another way when created with createElement(), and the difference shows up in whichever of the two paths you test less.

innerHTML will not parse it

Here is the one that costs an afternoon. You fetch a fragment of server-rendered HTML, complete with <template shadowrootmode>, assign it to innerHTML, and get no shadow root. No exception, no warning, no console message. The template is simply still there โ€” an ordinary, inert <template> element with your component's markup sitting unrendered inside its .content, precisely as step 06 described templates behaving. The card does not appear, and the markup you are staring at in DevTools looks correct.

const MARKUP = '<user-card><template shadowrootmode="open">...</template></user-card>'; // Does NOT create a shadow root โ€” the template stays inert, at any depth host.innerHTML = MARKUP; // Does create it โ€” and the name is telling you to trust the source host.setHTMLUnsafe(MARKUP); host.querySelector('user-card').shadowRoot; // ShadowRoot // But a template at the top level of the string has no parent inside the // fragment, so nothing adopts it โ€” including the element you called on host.setHTMLUnsafe('<template shadowrootmode="open">...</template>'); host.shadowRoot; // null โ€” still an inert <template> in host's children

Read that last pair carefully, because it is the same silent nothing arriving by a second route. The parser attaches each root to the template's parent within the fragment it is building, and the element you called the method on is not part of that fragment โ€” it is the context, not a node in the result. So setHTMLUnsafe() cannot give the context element a shadow root, however the string is written: a <template shadowrootmode> at the top level of the string has nothing to attach to and survives as an ordinary inert template. Wrap it in the host element it belongs to and the identical string works. If you need a root on the element itself, that is what attachShadow() is for.

This is deliberate, and the reason is worth understanding because it explains the second method's name. There is an enormous amount of existing code that assigns strings to innerHTML, much of it after passing the string through a sanitizer that walks the resulting tree. If innerHTML had been taught to create shadow roots, every one of those call sites would have gained the ability to attach content that a sanitizer written before this feature existed does not look inside โ€” markup that renders to the user while being invisible to the code that was supposed to inspect it. Silently changing what an old API does to old input was not an option, so the capability went into a new method, and that method was named after the promise you are making by calling it.

Element.setHTMLUnsafe() is the in-place form; Document.parseHTMLUnsafe() is the standalone one, returning a whole document. Both parse declarative shadow roots. Neither sanitizes anything. The rule is the ordinary one for injecting markup, only with sharper consequences: it is safe for HTML you generated, and it is never safe for HTML a user wrote.

Which is not the end of the story, because each has a sanitizing sibling and they are the ones to reach for when the HTML is not yours. Element.setHTML() and Document.parseHTML() parse declarative shadow roots and run the Sanitizer over the result, including inside the roots they just created: a string whose template holds <p onclick="x()">hi</p><script>โ€ฆ</script> comes back as a shadow root containing <p>hi</p>, handler and script both gone, where setHTMLUnsafe() would have kept them. No options are needed for that: a declarative template is consumed by the parser into a shadow root before the sanitizer walks the tree, so the allowed-element list never gets a say over it.

That list does have a say over everything else, and it is the real limit on using the safe pair with components. The built-in configuration allows a fixed set of known HTML elements, and your custom element is not among them โ€” so the host is removed and its shadow root goes with it.

// The declarative root survives sanitizing โ€” the host does not host.setHTML('<user-card><template shadowrootmode="open">โ€ฆ</template></user-card>'); host.querySelector('user-card'); // null โ€” dropped as an unknown element // A plain <template>, with no shadowrootmode, is dropped too host.setHTML('<div><template><p>tpl</p></template></div>'); host.innerHTML; // "<div></div>"

So the sanitizing pair is the right reach for untrusted markup, and it handles declarative shadow roots correctly โ€” but shipping components through it means widening the configuration to admit your own element names, deliberately and by name. That is a decision to make with your eyes open, not a default to lean on.

Serializing back out

The markup in this step has to come from somewhere. Writing it by hand is fine for one card and hopeless for a page of them, so the platform provides the other direction: a way to read a live element back out including its shadow roots, in the declarative form the parser understands.

const html = host.getHTML({ serializableShadowRoots: true });

getHTML() with no arguments is the innerHTML getter with a longer name: it walks the light DOM and skips shadow roots entirely, which is the symmetric counterpart of the setter refusing to create them. Pass serializableShadowRoots: true and every shadow root marked serializable is emitted as a <template shadowrootmode> in the output, nested exactly where the parser will want to find it. There is also a shadowRoots option taking an explicit array of roots to include, which is how a closed root โ€” whose reference only its own component holds โ€” can be serialized by that component.

Serializability is opt-in for the same family of reasons the parsing is. A root is included only if it was created with attachShadow({ mode: 'open', serializable: true }), or arrived from a template carrying shadowrootserializable. Forget the flag and getHTML() returns markup with the shadow roots quietly missing โ€” which will look, on the round trip, exactly like the innerHTML bug above, from the other end.

What this closes is the loop. A rendering environment โ€” a server, a build step, a headless browser โ€” can define the components, instantiate them, let them build their shadow roots the ordinary way, and then serialize the whole result to HTML that any browser will render without running any of it again.

Demo: a shadow root that arrives in the markup

Two <user-card> elements and one class. The first card is in the page's source with its shadow root written out as a template, so the parser builds it โ€” border, type scale, grey role line and all โ€” before the module at the bottom of the file has been requested. The status line inside it reads "Not yet upgraded" because that string is in the markup; by the time you see the frame the definition has loaded and overwritten it, which is what the upgrade did. Reload with JavaScript disabled and the card renders identically, still saying it has not been upgraded.

The greet button proves the second half. It is in the shadow root the parser built, and nothing in the page attached anything to it until connectedCallback ran and adopted that root โ€” markup from the parser, listener from the class, one component.

Then press "Add a card at runtime" and watch the difference. The same class handles the new element, but there is no declarative template for it, so the guard falls through to attachShadow() and the fallback string โ€” which contains markup and no <style>. Style is scoped per root, so the runtime card gets none of the first card's appearance, and no rule in the page can supply it either. That is not a flaw in the demo; it is the same boundary from step 04, seen from the angle where forgetting one thing costs you every rule at once.

What this means for a static site

This page was generated at build time. Its HTML was written to disk before you asked for it, and the server did nothing but hand over a file. That is a good arrangement, and it has one well-known hole: anything on the page made of components is not in that file. The generator emits <my-card> and the browser has to be told separately what one is.

Declarative shadow DOM is what closes the hole without abandoning the arrangement. A generator that can render your components at build time and serialize the result โ€” the getHTML() round trip from two sections ago โ€” writes files whose components are already components: styled, composed, and painted on first load, with the JavaScript arriving afterwards to add the behavior. That is not a new idea. It is precisely the argument step 01 made about semantic markup, applied one level up: ship the thing itself, enhance it after.

Be honest about what is not solved. The build has to produce the markup the client would have produced, and "would have produced" is doing a great deal of work in that sentence โ€” a component that reads the viewport, or the time, or localStorage renders differently in a build environment than in a browser, and the mismatch surfaces as a component that visibly changes shape a moment after load, or as a listener attached to a node that is not the node the user sees. Rendering the components at build time also means running them somewhere with a DOM, which is a constraint on how the components may be written.

And there is no standard, framework-free tooling for any of it yet. The framework-attached implementations are real and shipping; the generic pipeline โ€” define, render, serialize, guard on this.shadowRoot โ€” is something you presently assemble yourself from the pieces this step describes. Every piece exists. The convention does not.

Where the series ends

Nine steps, and the shape they traced is the one the series index claimed at the start: a component is a name, some markup, some behavior, and a boundary, and the platform ships all four as separate things.

Steps 01 to 03 were the name โ€” a hyphenated tag as semantics with no script at all, then customElements.define(), upgrade, and the attribute-versus-property surface that makes an element addressable from HTML. Step 04 and step 07 were the boundary: what it costs, and how to design the few deliberate holes anyone else will ever be able to style through. Step 06 and this one were the markup โ€” inert templates and slots, then markup that arrives already rendered. Step 05 and step 08 were the behavior: the lifecycle, the listener that outlives its element, and what it takes for the browser to treat your element as one of its own form controls.

The through-line is that those four are separable, and that knowing which of them you actually need is most of the skill. Nearly every component in this series could have been built with fewer of the four than it used. A semantic tag and a stylesheet is a legitimate component. A custom element with no shadow root is a legitimate component, and frequently the better one โ€” it participates in forms, inherits the page's CSS, and is findable by everything that walks the document. Reaching for all four every time is the habit a framework trains, because a framework hands you all four as one object. The platform does not, and that is the advantage, not the friction: you pay for each piece separately, so you can decline the ones you do not need.

For the layer underneath โ€” how the parser builds the tree these components attach to, what the event loop is doing while a module downloads, and why the flash in this step's first section happens where it does โ€” see The Browser for Programmers. For the sibling topics, and the other platform capabilities that pair with this one, see the APIs series.