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
Live Demo
CSS Styling
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
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
CSS Layers
Layers control cascade specificity:
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:
Basic Custom Element Class
Every custom element extends HTMLElement and must call super() first in the constructor:
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
HTML Usage
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
HTML Usage
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
HTML Usage
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
Demonstration
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.
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.
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
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
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
HTMLElementdirectlyclass 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
observedAttributesandattributeChangedCallback- Custom states with
ElementInternalsand 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()vsconnectedCallback()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.
Property Getters and Setters
Use getters and setters to create a clean API and maintain synchronization between properties and attributes.
Basic Property Pattern
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!
Usage Examples
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.
Demo 1: Toggle Switch Component
A fully functional toggle switch demonstrating properties, attributes, events, and state management.
Features
checked- Boolean property/attribute for toggle statedisabled- Boolean property/attribute to disable interactionlabel- String property/attribute for accessibilitychange- Custom event fired when toggled
Implementation
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 valuemax- Maximum value (default: 100)color- String property for bar color customizationshow-percentage- Boolean to display percentage textpercentage- Computed getter for percentage valueprogress-change- Custom event fired when value changes- Automatic clamping of values to valid range (0 to max)
- Proper ARIA attributes for accessibility
Implementation
HTML Usage
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
HTML Usage
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
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
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
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
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
Shadow DOM Implementation
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)
2. CSS Parts (::part)
3. Host Context (:host-context) — Chromium only
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.
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
AbortControllerthat 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
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.
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
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
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.
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
Pattern 2: Observer Management
Pattern 3: Event Listener Management
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
clearTimeoutorclearInterval - External references - Set to null or use WeakMap
Cleanup Checklist Pattern
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.
Advanced Lifecycle Patterns
Pattern 1: Lazy Initialization
Defer expensive setup until the element is actually connected to the DOM:
Pattern 2: Handling Reconnection
Components can be removed and re-added. Handle this gracefully:
Pattern 3: Debouncing Attribute Changes
Avoid expensive re-renders when attributes change rapidly:
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.
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.
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.
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.
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?".
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
adoptedStyleSheetsand sharing one stylesheet across every instance- Why
::partis 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
::partis 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.
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.
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.
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.
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.
"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-bgis 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 formAssociatedand whatElementInternalshands 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
ElementInternalsgives 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.
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.
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.
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.
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.
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() 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.
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
innerHTMLwill not parse a declarative shadow root, and whatsetHTMLUnsafe()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.
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: truethat 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.
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.
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.
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.
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.
