Journey to Web Components: From Fundamentals to Advanced UI Architecture
The Future of Web Components
Where Innovation Meets Standardization

Which Web Component features can you use today, and which are still ideas? This post sorts them, with a status table you can check against your own browser.
Some features are ready now, some work in only some browsers, and a few are only proposals. Knowing which is which keeps us from building on something that does not exist.
Prerequisites in two lines: a custom element is a JavaScript class registered with customElements.define(), and a shadow root gives it private markup and styles. Everything below extends those two ideas.
What ships today and what does not#
"Works" in the second column means a small script ran in headless Chrome 156 while editing this post. Firefox and Safari were not tested by us. The Status column was checked against webstatus.dev and MDN on 26 October 2026, so treat it as a snapshot and look again before you rely on a row.
| Feature | In Chrome 156 | Status, Oct 2026 |
|---|---|---|
::part(): style what a component exposes | Works | Ships today |
:state(): style a component by its own state | Works | Baseline newly available |
Declarative Shadow DOM: shadowrootmode | Works | Baseline widely available |
Manual slot assignment: slot.assign() | Works | Baseline widely available |
@scope: limit where a CSS rule applies | Works | Baseline newly available |
| Scoped registries | Works | Chrome, Edge, Safari only |
attachSlot() | undefined | Not an API |
accessibleRole property | Not present | Not an API |
| Declarative custom elements | Not testable | Proposal |
The dates behind the Status column, from webstatus.dev and MDN on 26 October 2026:
:state(): newly available since May 2024 (Chrome 125, Firefox 126, Safari 17.4).- Declarative Shadow DOM: newly available in February 2024 (Chrome 111, Firefox 123, Safari 16.4) and widely available since August 2026. The older
shadowrootattribute no longer works. - Manual slot assignment: widely available in all three engines since March 2023.
@scope: newly available since March 2026.- Scoped registries: Chrome and Edge 146, Safari 26. Firefox has not shipped it. It is in Nightly, and Mozilla has proposed shipping it in Firefox 159, so check before you rely on it.
::part()was not re-checked for this update.
The Rising Tide: Emerging Trends#
The rest of this post covers libraries, state and a few patterns. Most of it is built on the standards above, not added to the browser, and none of it is needed to write a custom element.
Web Component Libraries#
You do not need a library to write a custom element, and every example above works without one. A library saves boilerplate for the parts you end up writing again and again.
Lit, from Google, is a small base class that adds reactive properties and templates on top of custom elements. Stencil is a compiler: you write TypeScript and JSX and it produces standards-based custom elements. FAST and Shoelace are the other two names that come up most. Each project changes its name, its version and its advice over time, so check its own site for its current state before you pick one. We did not re-check them for this update.
The cost is the usual one. A library is one more thing to learn and to keep up to date, and shadow DOM still makes global styling harder whichever one you choose.
Is State Management Going Native?: Beyond Properties and Attributes#
The browser gives you less state management than a framework does, and that is fine. The part it does give you is :state(): a component can expose states such as "loading" or "expanded" to CSS without adding classes the page could overwrite. It has been Baseline newly available since May 2024 (Chrome 125, Firefox 126, Safari 17.4). Stores, signals and reducers are libraries and patterns, not platform features, and the examples below are patterns you can write yourself.
---html
<user-profile></user-profile>
---css
body {
margin: 0;
padding: 20px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #f0f0f0;
}
}
@media (prefers-color-scheme: light) {
body {
background-color: #f8f9fa;
color: #333;
}
}
---js
// Simple state management pattern for Web Components
class UserProfile extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Initialize state
this.state = {
name: 'Guest User',
role: 'Visitor',
isLoggedIn: false,
darkMode: window.matchMedia('(prefers-color-scheme: dark)').matches };
// Listen for changes in color scheme preference
this.colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
this.colorSchemeMedia.addEventListener('change', e => {
this.setState({ darkMode: e.matches });
});
this.render();
}
// State management method
setState(newState) {
this.state = { ...this.state, ...newState };
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
max-width: 300px;
padding: 20px;
border: 1px solid ${this.state.darkMode ? '#555' : '#ddd'};
border-radius: 8px;
background-color: ${this.state.darkMode ? '#222' : '#fff'};
color: ${this.state.darkMode ? '#eee' : '#333'};
}
.profile {
display: flex;
flex-direction: column;
align-items: center;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: ${this.state.isLoggedIn ? '#4CAF50' : this.state.darkMode ? '#555' : '#ccc'};
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: white;
font-size: 32px;
}
.name {
font-size: 20px;
font-weight: bold;
margin-bottom: 4px;
}
.role {
color: ${this.state.darkMode ? '#aaa' : '#666'};
margin-bottom: 16px;
}
button {
background-color: ${this.state.isLoggedIn ? '#f44336' : '#4CAF50'};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
opacity: 0.9;
}
</style>
<div class="profile">
<div class="avatar">${this.state.name.charAt(0)}</div>
<div class="name">${this.state.name}</div>
<div class="role">${this.state.role}</div>
<button id="toggle-login">
${this.state.isLoggedIn ? 'Logout' : 'Login'}
</button>
</div>
`;
// Add event listeners
this.shadowRoot.querySelector('#toggle-login').addEventListener('click', () => {
if (this.state.isLoggedIn) {
this.setState({
name: 'Guest User',
role: 'Visitor',
isLoggedIn: false });
} else {
this.setState({
name: 'Jane Doe',
role: 'Administrator',
isLoggedIn: true });
}
});
}
disconnectedCallback() {
// Clean up event listener when component is removed
if (this.colorSchemeMedia) {
this.colorSchemeMedia.removeEventListener('change', this.handleColorSchemeChange);
}
}}
customElements.define('user-profile', UserProfile);
Context: Sharing State Without Tight Coupling#
A context-style provider is a pattern, not a platform feature. A parent component holds a value, and the components inside it ask for that value by dispatching an event, so the provider and its consumers do not need to know about each other. A ThemeProvider is the usual example, and the one below is small enough to read in a minute. For plain style values, CSS custom properties are still simpler.
---html
<theme-provider>
<theme-consumer></theme-consumer>
<theme-toggler></theme-toggler>
<theme-controller></theme-controller>
</theme-provider>
---js
// Context-like pattern for Web Components
class ThemeContext {
constructor() {
// Check for user preference
const prefersDarkMode =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
this.theme = prefersDarkMode ? "dark" : "light";
this.accentColor = "#2196F3";
this.fontSize = "medium";
this.borderRadius = "4px";
this.listeners = new Set();
// Track the last changed setting to help with animations
this.lastChanged = null;
// Listen for system theme changes
this.mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.handleSystemThemeChange = e => {
this.setTheme(e.matches ? "dark" : "light");
};
// Add listener for system theme changes if supported
if (this.mediaQuery.addEventListener) {
this.mediaQuery.addEventListener("change", this.handleSystemThemeChange);
}
// Apply initial theme to body/html/root
this.applyThemeToDocument(this.theme);
}
getTheme() {
return this.theme;
}
setTheme(newTheme) {
this.theme = newTheme;
this.lastChanged = "theme";
this.applyThemeToDocument(newTheme);
this.notifyListeners();
}
applyThemeToDocument(theme) {
// Set theme on html and body elements
document.documentElement.style.backgroundColor =
theme === "light" ? "#f5f5f5" : "#121212";
document.documentElement.style.transition = "background-color 0.3s";
document.body.style.backgroundColor =
theme === "light" ? "#f5f5f5" : "#121212";
document.body.style.color = theme === "light" ? "#333333" : "#ffffff";
document.body.style.transition = "background-color 0.3s, color 0.3s";
// Add/remove class on document root for additional styling hooks
if (theme === "dark") {
document.documentElement.classList.add("dark-theme");
} else {
document.documentElement.classList.remove("dark-theme");
}
}
getAccentColor() {
return this.accentColor;
}
setAccentColor(newColor) {
this.accentColor = newColor;
this.lastChanged = "accentColor";
this.notifyListeners();
}
getFontSize() {
return this.fontSize;
}
setFontSize(newSize) {
this.fontSize = newSize;
this.lastChanged = "fontSize";
this.notifyListeners();
}
getBorderRadius() {
return this.borderRadius;
}
setBorderRadius(newRadius) {
this.borderRadius = newRadius;
this.lastChanged = "borderRadius";
this.notifyListeners();
}
getLastChanged() {
return this.lastChanged;
}
// Get the current theme as a complete object
getCurrentThemeSettings() {
return {
theme: this.theme,
accentColor: this.accentColor,
fontSize: this.fontSize,
borderRadius: this.borderRadius };
}
subscribe(listener) {
this.listeners.add(listener);
}
unsubscribe(listener) {
this.listeners.delete(listener);
}
notifyListeners() {
this.listeners.forEach((listener) =>
listener(this.getCurrentThemeSettings()));
}
disconnectMediaQuery() {
// Clean up media query listener if it exists
if (this.mediaQuery && this.mediaQuery.removeEventListener) {
this.mediaQuery.removeEventListener("change", this.handleSystemThemeChange);
}
}}
// Global context instance
const themeContext = new ThemeContext();
// Add window unload handler to clean up listeners
window.addEventListener("unload", () => {
themeContext.disconnectMediaQuery();
});
// Provider component
class ThemeProvider extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
padding: 20px;
background-color: ${
themeContext.getTheme() === "light" ? "#ffffff" : "#333333"
};
color: ${themeContext.getTheme() === "light" ? "#333333" : "#ffffff"};
border-radius: 8px;
transition: background-color 0.3s, color 0.3s;
}
.container {
max-width: 600px;
margin: 0 auto;
}
</style>
<div class="container">
<h2>Theme Context Demo</h2>
<slot></slot>
</div>
`;
// Update when theme changes
this.updateTheme = settings => {
const theme = settings.theme;
const styleElement = this.shadowRoot.querySelector("style");
// Update the style element with new theme colors
styleElement.textContent = `
:host {
display: block;
font-family: system-ui, sans-serif;
padding: 20px;
background-color: ${theme === "light" ? "#ffffff" : "#333333"};
color: ${theme === "light" ? "#333333" : "#ffffff"};
border-radius: 8px;
transition: background-color 0.3s, color 0.3s;
}
.container {
max-width: 600px;
margin: 0 auto;
}
`;
};
themeContext.subscribe(this.updateTheme);
}
disconnectedCallback() {
themeContext.unsubscribe(this.updateTheme);
}}
// Consumer component
class ThemeConsumer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.render();
// Update when theme changes
this.updateTheme = settings => {
// Store which value changed to highlight it for the user
this.lastChanged = themeContext.getLastChanged();
this.render();
// Reset highlight after animation completes
setTimeout(() => {
this.lastChanged = null;
this.render();
}, 1500);
};
themeContext.subscribe(this.updateTheme);
}
disconnectedCallback() {
themeContext.unsubscribe(this.updateTheme);
}
render() {
const settings = themeContext.getCurrentThemeSettings();
const theme = settings.theme;
const accentColor = settings.accentColor;
const fontSize = settings.fontSize;
const borderRadius = settings.borderRadius;
const lastChanged = this.lastChanged;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
margin: 16px 0;
padding: 16px;
border-radius: ${borderRadius};
border: 1px solid ${theme === "light" ? "#ddd" : "#555"};
background-color: ${theme === "light" ? "#f9f9f9" : "#444"};
color: ${theme === "light" ? "#333" : "#eee"};
transition: all 0.3s;
}
h3 {
margin-top: 0;
margin-bottom: 12px;
color: ${accentColor};
font-size: ${
fontSize === "small" ?
"16px" :
fontSize === "medium" ?
"18px" :
"20px"
};
transition: all 0.3s;
}
.theme-properties {
display: grid;
grid-template-columns: 120px 1fr;
grid-gap: 8px;
}
.property-name {
font-weight: bold;
opacity: 0.8;
}
.property-value {
font-family: monospace;
padding: 2px 6px;
border-radius: ${borderRadius};
background-color: ${
theme === "light" ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.1)"
};
transition: all 0.3s;
}
.property-highlight {
position: relative;
animation: pulse 1.5s ease-in-out;
}
.property-highlight::before {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: ${accentColor};
opacity: 0.2;
border-radius: ${borderRadius};
z-index: -1;
}
.color-preview {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.demo-text {
margin-top: 16px;
padding: 12px;
border-radius: ${borderRadius};
border-left: 4px solid ${accentColor};
font-size: ${
fontSize === "small" ?
"14px" :
fontSize === "medium" ?
"16px" :
"18px"
};
background-color: ${
theme === "light" ? "rgba(0,0,0,0.02)" : "rgba(255,255,255,0.05)"
};
transition: all 0.3s;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.03); }
100% { transform: scale(1); }
}
</style>
<div>
<h3>Theme State Observer</h3>
<div class="theme-properties">
<span class="property-name">Theme:</span>
<span class="property-value ${
lastChanged === "theme" ? "property-highlight" : ""
}">
${theme}
</span>
<span class="property-name">Accent Color:</span>
<span class="property-value ${
lastChanged === "accentColor" ? "property-highlight" : ""
}">
<span class="color-preview" style="background-color: ${accentColor};"></span>
${accentColor}
</span>
<span class="property-name">Font Size:</span>
<span class="property-value ${
lastChanged === "fontSize" ? "property-highlight" : ""
}">
${fontSize}
</span>
<span class="property-name">Border Radius:</span>
<span class="property-value ${
lastChanged === "borderRadius" ? "property-highlight" : ""
}">
${borderRadius}
</span>
</div>
<div class="demo-text">
This text demonstrates the current theme settings. The border radius, font size,
and colors are all controlled by the theme context.
</div>
</div>
`;
}}
// Theme toggler component
class ThemeToggler extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// Create bound event handler to avoid recreating it
this.handleToggle = this.handleToggle.bind(this);
// Subscribe to theme changes
this.updateTheme = () => {
this.render();
};
themeContext.subscribe(this.updateTheme);
this.render();
}
disconnectedCallback() {
// Clean up event listeners and subscriptions
themeContext.unsubscribe(this.updateTheme);
this.removeToggleListener();
}
removeToggleListener() {
const button = this.shadowRoot.querySelector("#theme-toggle");
if (button) {
button.removeEventListener("click", this.handleToggle);
}
}
handleToggle() {
const currentTheme = themeContext.getTheme();
themeContext.setTheme(currentTheme === "light" ? "dark" : "light");
}
render() {
// Clean up previous event listeners before re-rendering
this.removeToggleListener();
const theme = themeContext.getTheme();
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
margin: 16px 0;
padding: 16px;
border-radius: 4px;
border: 1px solid ${theme === "light" ? "#ddd" : "#555"};
background-color: ${theme === "light" ? "#f9f9f9" : "#444"};
transition: background-color 0.3s, border-color 0.3s;
}
button {
background-color: ${theme === "light" ? "#4CAF50" : "#66BB6A"};
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
button:hover {
background-color: ${theme === "light" ? "#3e8e41" : "#4CAF50"};
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
</style>
<div>
<p>Click the button to toggle the theme:</p>
<button id="theme-toggle">
Switch to ${theme === "light" ? "Dark" : "Light"} Theme
</button>
</div>
`;
// Add event listener
this.shadowRoot.
querySelector("#theme-toggle").
addEventListener("click", this.handleToggle);
}}
// Theme controller component
class ThemeController extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// Get current theme settings
this.theme = themeContext.getTheme();
this.accentColor = themeContext.getAccentColor();
this.fontSize = themeContext.getFontSize();
this.borderRadius = themeContext.getBorderRadius();
// Define preset accent colors
this.accentColors = [
"#2196F3", // Blue (default)
"#F44336", // Red
"#4CAF50", // Green
"#FF9800", // Orange
"#9C27B0", // Purple
"#00BCD4", // Cyan
"#607D8B" // Blue Grey
];
// Define preset font sizes
this.fontSizes = ["small", "medium", "large"];
// Define preset border radius values
this.borderRadiusOptions = ["0px", "4px", "8px", "12px", "16px"];
this.render();
// Update when theme changes
this.updateTheme = settings => {
this.theme = settings.theme;
this.accentColor = settings.accentColor;
this.fontSize = settings.fontSize;
this.borderRadius = settings.borderRadius;
this.render();
};
themeContext.subscribe(this.updateTheme);
}
disconnectedCallback() {
themeContext.unsubscribe(this.updateTheme);
}
addEventListeners() {
// Theme toggle
const themeToggle = this.shadowRoot.getElementById("theme-toggle");
themeToggle.addEventListener("click", () => {
themeContext.setTheme(this.theme === "light" ? "dark" : "light");
});
// Accent color selection
const colorButtons = this.shadowRoot.querySelectorAll(".color-option");
colorButtons.forEach(button => {
button.addEventListener("click", () => {
const color = button.getAttribute("data-color");
themeContext.setAccentColor(color);
});
});
// Font size selection
const fontSizeSelect = this.shadowRoot.getElementById("font-size-select");
fontSizeSelect.value = this.fontSize;
fontSizeSelect.addEventListener("change", () => {
themeContext.setFontSize(fontSizeSelect.value);
});
// Border radius slider
const radiusSlider = this.shadowRoot.getElementById("radius-slider");
const radiusValue = this.shadowRoot.getElementById("radius-value");
// Set initial value
const currentRadiusIndex = this.borderRadiusOptions.indexOf(
this.borderRadius);
radiusSlider.value = currentRadiusIndex >= 0 ? currentRadiusIndex : 1;
radiusValue.textContent = this.borderRadius;
radiusSlider.addEventListener("input", () => {
const newRadius = this.borderRadiusOptions[radiusSlider.value];
radiusValue.textContent = newRadius;
themeContext.setBorderRadius(newRadius);
});
}
render() {
const theme = this.theme;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
margin: 16px 0;
padding: 16px;
border-radius: ${this.borderRadius};
border: 1px solid ${theme === "light" ? "#ddd" : "#555"};
background-color: ${theme === "light" ? "#f9f9f9" : "#444"};
color: ${theme === "light" ? "#333" : "#eee"};
transition: all 0.3s;
font-family: system-ui, sans-serif;
}
h3 {
margin-top: 0;
color: ${this.accentColor};
transition: color 0.3s;
}
.control-group {
margin-bottom: 16px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
.theme-toggle {
cursor: pointer;
background-color: ${theme === "light" ? "#333" : "#f5f5f5"};
color: ${theme === "light" ? "#fff" : "#333"};
border: none;
border-radius: ${this.borderRadius};
padding: 8px 16px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
width: 140px;
transition: background-color 0.3s, color 0.3s;
}
.theme-toggle .icon {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: ${theme === "light" ? "#f5f5f5" : "#333"};
margin-left: 8px;
position: relative;
}
.theme-toggle .icon::before {
content: '';
position: absolute;
top: 3px;
left: ${theme === "light" ? "3px" : "12px"};
width: 10px;
height: 10px;
border-radius: 50%;
background-color: ${theme === "light" ? "#333" : "#f5f5f5"};
transition: left 0.3s;
}
.color-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.color-option {
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
position: relative;
border: 2px solid ${theme === "light" ? "#ddd" : "#555"};
transition: transform 0.2s;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.active {
border: 2px solid ${theme === "light" ? "#333" : "#fff"};
}
.color-option.active::after {
content: "✓";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: ${this.isDarkColor(this.accentColor) ? "#fff" : "#333"};
font-size: 18px;
font-weight: bold;
}
select, input[type="range"] {
width: 100%;
padding: 6px;
border-radius: ${this.borderRadius};
border: 1px solid ${theme === "light" ? "#ddd" : "#555"};
background-color: ${theme === "light" ? "#fff" : "#333"};
color: ${theme === "light" ? "#333" : "#fff"};
}
.slider-container {
display: flex;
align-items: center;
gap: 12px;
}
.slider-container input {
flex: 1;
}
.value-display {
min-width: 40px;
text-align: center;
font-family: monospace;
padding: 4px 8px;
background-color: ${
theme === "light" ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.1)"
};
border-radius: ${this.borderRadius};
}
</style>
<div>
<h3>Theme Controller</h3>
<div class="control-group">
<label>Theme Mode</label>
<button id="theme-toggle" class="theme-toggle">
${theme === "light" ? "Light Mode" : "Dark Mode"}
<span class="icon"></span>
</button>
</div>
<div class="control-group">
<label>Accent Color</label>
<div class="color-options">
${this.accentColors.
map(
color => `
<div
class="color-option ${
color === this.accentColor ? "active" : ""
}"
style="background-color: ${color};"
data-color="${color}">
</div>
`).
join("")}
</div>
</div>
<div class="control-group">
<label>Font Size</label>
<select id="font-size-select">
${this.fontSizes.
map(
size => `
<option value="${size}" ${
this.fontSize === size ? "selected" : ""
}>${size.charAt(0).toUpperCase() + size.slice(1)}</option>
`).
join("")}
</select>
</div>
<div class="control-group">
<label>Border Radius</label>
<div class="slider-container">
<input
id="radius-slider"
type="range"
min="0"
max="${this.borderRadiusOptions.length - 1}"
value="${this.borderRadiusOptions.indexOf(this.borderRadius)}"
/>
<div id="radius-value" class="value-display">${
this.borderRadius
}</div>
</div>
</div>
</div>
`;
this.addEventListeners();
}
// Helper to determine if a color is dark
isDarkColor(color) {
// Convert hex to RGB
const hex = color.replace("#", "");
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
// Calculate brightness
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128;
}}
// Register the theme controller component
customElements.define("theme-controller", ThemeController);
// Register the components
customElements.define("theme-provider", ThemeProvider);
customElements.define("theme-consumer", ThemeConsumer);
customElements.define("theme-toggler", ThemeToggler);
Declarative Shadow DOM: Server-Side Renaissance#
One of the most significant limitations of Web Components has long been server-side rendering capabilities. The Declarative Shadow DOM (DSD) is changing this landscape, enabling Web Components to be rendered on the server before reaching the browser.
Enhance.dev is built around this approach. Here is the syntax. Note the attribute is shadowrootmode; the older shadowroot spelling from early drafts no longer creates a shadow root (checked in Chrome 156):
<my-card>
<template shadowrootmode="open">
<style>:host { display: block; }</style>
<slot name="title"></slot>
</template>
<h1 slot="title">Hello from the server!</h1>
</my-card>
The browser builds the shadow tree while parsing the HTML, so the component paints before any JavaScript runs.
Check out this interactive demo to see this pattern in action:
---html
<my-card counter="1">
<template shadowrootmode="open">
<style>
:host {
display: block;
border: 1px solid var(--border-color, #ccc);
border-radius: 8px;
padding: 16px;
font-family: system-ui, sans-serif;
background-color: var(--background-color, #fff);
color: var(--text-color, #333);
}
.card-header {
border-bottom: 1px solid var(--divider-color, #eee);
padding-bottom: 10px;
margin-bottom: 10px;
}
::slotted(h1) {
margin-top: 0;
color: var(--text-color, #333);
}
.counter {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--divider-color, #eee);
}
button {
background-color: var(--background-color);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 5px 10px;
margin-right: 10px;
border-radius: 4px;
}
</style>
<div class="card-header">
<slot name="title">Default Title</slot>
</div>
<div class="card-body">
<slot>Default content</slot>
</div>
<div class="counter">
<p>Count: <span id="count">0</span></p>
<button id="decrement">-</button>
<button id="increment">+</button>
</div>
</template>
<h1 slot="title">Hello from Declarative Shadow DOM</h1>
<p>
This card demonstrates Declarative Shadow DOM for server-side rendering.
</p>
</my-card>
<div class="explanation">
<h2>Benefits of Declarative Shadow DOM</h2>
<p>
Declarative Shadow DOM provides significant advantages over imperative shadow DOM creation:
</p>
<ul>
<li><strong>Server-Side Rendering:</strong> Components with encapsulated styling can be rendered on the server, improving initial page load performance</li>
<li><strong>Zero Hydration Cost:</strong> No JavaScript is required to initialize the shadow DOM, making components immediately interactive</li>
<li><strong>Progressive Enhancement:</strong> Components work even before JavaScript loads or if JavaScript fails</li>
<li><strong>SEO Friendly:</strong> Content is available in the initial HTML, making it more accessible to search engines</li>
<li><strong>Simplified Component Definition:</strong> No need for separate template files or string literals - the component structure is defined directly in HTML</li>
</ul>
<p>
The component above demonstrates these benefits while also implementing dark mode support,
showing how declarative shadow DOM can be both highly performant and user-friendly.
</p>
</div>
<h2 class="code-header">Declarative Shadow DOM Code:</h2>
<pre><code><my-card counter="1" >
<template shadowrootmode="open">
<style>
:host {
display: block;
border: 1px solid var(--border-color, #ccc);
border-radius: 8px;
padding: 16px;
font-family: system-ui, sans-serif;
background-color: var(--background-color, #fff);
color: var(--text-color, #333);
}
.card-header {
border-bottom: 1px solid var(--divider-color, #eee);
padding-bottom: 10px;
margin-bottom: 10px;
}
::slotted(h1) {
margin-top: 0;
color: var(--text-color, #333);
}
.counter {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--divider-color, #eee);
}
button {
background-color: var(--background-color);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 5px 10px;
margin-right: 10px;
border-radius: 4px;
}
</style>
<div class="card-header">
<slot name="title">Default Title</slot>
</div>
<div class="card-body">
<slot>Default content</slot>
</div>
<div class="counter">
<p>Count: <span id="count">0</span></p>
<button id="decrement">-</button>
<button id="increment">+</button>
</div>
</template>
<h1 slot="title">Hello from Declarative Shadow DOM</h1>
<p>
This card demonstrates Declarative Shadow DOM for server-side rendering.
</p>
</my-card></code></pre>
<div class="explanation">
<h2>Adding JavaScript Logic with Declarative Shadow DOM</h2>
<p>
While declarative shadow DOM provides the HTML structure and styling, you can still add JavaScript logic through custom elements:
</p>
<ul>
<li><strong>Progressive Enhancement:</strong> The component renders with its structure and styling before JavaScript loads</li>
<li><strong>Logic Attachment:</strong> JavaScript enhances the pre-rendered component with behavior after loading</li>
<li><strong>Works either way:</strong> The custom element class works with both declaratively and imperatively created shadow DOM</li>
<li><strong>Attribute Reactivity:</strong> Using attributes and the built-in lifecycle callbacks enables state management</li>
</ul>
<p>
This approach gives you the best of both worlds: instant rendering with declarative shadow DOM,
plus fully interactive behavior once JavaScript executes.
</p>
</div>
---css
:root {
color-scheme: light dark;
--background-color: #ffffff;
--text-color: #333333;
--border-color: #cccccc;
--divider-color: #eeeeee;
}
@media (prefers-color-scheme: dark) {
:root {
--background-color: #222222;
--text-color: #f0f0f0;
--border-color: #555555;
--divider-color: #444444;
}
}
body {
background-color: var(--background-color);
color: var(--text-color);
margin: 20px;
}
pre {
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 15px;
overflow: auto;
margin-top: 30px;
}
code {
font-family: monospace;
color: var(--text-color);
white-space: pre;
}
.code-header {
font-weight: bold;
margin-bottom: 10px;
margin-top: 30px;
color: var(--text-color);
}
---js
// Check and polyfill if necessary
if (!HTMLTemplateElement.prototype.hasOwnProperty("shadowRootMode")) {
document.
querySelectorAll("template[shadowrootmode]").
forEach(template => {
const mode = template.getAttribute("shadowrootmode");
const shadowRoot = template.parentNode.attachShadow({ mode });
shadowRoot.appendChild(template.content);
template.remove();
});
}
// Register the element with behavior
customElements.define("my-card", class extends HTMLElement {
static get observedAttributes() {
return ['counter'];
}
constructor() {
super();
// Get access to shadow DOM elements (works regardless of how shadow DOM was created)
this._setupShadowDOM();
}
_setupShadowDOM() {
// If the shadow root already exists (from declarative shadow DOM)
// we can use it directly, otherwise it would have been created by the polyfill
const shadowRoot = this.shadowRoot;
// Get references to elements in the shadow DOM
this._countDisplay = shadowRoot.querySelector('#count');
this._incrementBtn = shadowRoot.querySelector('#increment');
this._decrementBtn = shadowRoot.querySelector('#decrement');
// Add event listeners
this._incrementBtn.addEventListener('click', () => this._incrementCount());
this._decrementBtn.addEventListener('click', () => this._decrementCount());
// Initialize with attribute value
this._updateCount(parseInt(this.getAttribute('counter') || '0'));
}
connectedCallback() {
// Make sure the count is updated with the latest attribute value
// This handles the case where attributes might have changed before connection
this._updateCount(parseInt(this.getAttribute('counter') || '0'));
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'counter' && this._countDisplay) {
this._updateCount(parseInt(newValue || '0'));
}
}
_incrementCount() {
const current = parseInt(this.getAttribute('counter') || '0');
this.setAttribute('counter', current + 1);
}
_decrementCount() {
const current = parseInt(this.getAttribute('counter') || '0');
this.setAttribute('counter', Math.max(0, current - 1));
}
_updateCount(value) {
if (this._countDisplay) {
this._countDisplay.textContent = value;
}
}});
State Management Evolves#
State management in complex Web Components can quickly become unwieldy. The community has answered with a few small patterns, shown below.
Signals libraries offer a React-inspired state model without the framework dependencies, while Lit State provides reactive state management specifically tailored to component systems. Most exciting is the Custom State Pseudo-Class, which now ships in browsers and lets you style components based on their internal state. A component adds a state with this.attachInternals().states.add('loading'), and CSS matches it with :state():
my-element:state(loading) {
opacity: 0.5;
}
These are patterns you can write today. None of them is a browser feature.
Here is another interactive example that uses ElementInternals.states and the :state() pseudo-class, styling the component from the page and from inside its own shadow root.
---html
<state-component></state-component>
---css
body {
font-family: system-ui, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
transition: background-color 0.3s, color 0.3s;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #e0e0e0;
}
}
/* Custom states are styled from outside with :state(), with no attributes involved */
state-component:state(loading) {
opacity: 0.5;
pointer-events: none;
}
state-component:state(error) {
outline: 3px solid #f44336;
outline-offset: 4px;
border-radius: 8px;
}
---js
class StateComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// ElementInternals.states is a CustomStateSet: whatever it holds,
// CSS can match with :state(name), from outside or via :host(:state(name)).
this._internals = this.attachInternals();
// Initial state
this.state = "idle";
this._internals.states.add(this.state);
// One bound listener, so it can be removed again
this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this);
// Auto-reset timer reference
this.resetTimer = null;
// Default auto-reset duration (ms)
this.resetDuration = 3000;
// Check dark mode preference
this.darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
this.render();
}
static get observedAttributes() {
return ['reset-duration', 'dark-mode'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'reset-duration' && newValue) {
this.resetDuration = parseInt(newValue, 10);
} else if (name === 'dark-mode') {
this.darkMode = newValue === 'true';
this.render();
}
}
connectedCallback() {
// Listen for system color scheme changes
this.colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
this.colorSchemeMedia.addEventListener('change', this.handleColorSchemeChange);
}
disconnectedCallback() {
// Clean up timers when element is removed
this.clearResetTimer();
// Remove event listener
if (this.colorSchemeMedia) {
this.colorSchemeMedia.removeEventListener('change', this.handleColorSchemeChange);
}
}
handleColorSchemeChange(e) {
this.darkMode = e.matches;
this.render();
}
clearResetTimer() {
if (this.resetTimer) {
clearTimeout(this.resetTimer);
this.resetTimer = null;
// Also clear the progress animation
const progressBar = this.shadowRoot.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.animation = 'none';
}
}
}
setState(newState) {
this.state = newState;
this._internals.states.clear();
this._internals.states.add(newState);
// Clear any existing timers
this.clearResetTimer();
// Set automatic reset for non-idle states
if (newState !== "idle") {
this.resetTimer = setTimeout(() => {
this.setState("idle");
}, this.resetDuration);
// Update progress bar animation duration after render
setTimeout(() => {
const progressBar = this.shadowRoot.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.animation = `progress ${this.resetDuration / 1000}s linear forwards`;
}
}, 0);
}
this.render();
}
toggleDarkMode() {
this.darkMode = !this.darkMode;
this.setAttribute('dark-mode', this.darkMode);
this.render();
}
render() {
let icon, message, ariaLive;
switch (this.state) {
case "loading":
icon = "⏳";
message = "Loading...";
ariaLive = "polite";
break;
case "success":
icon = "✅";
message = "Operation successful!";
ariaLive = "assertive";
break;
case "error":
icon = "❌";
message = "An error occurred.";
ariaLive = "assertive";
break;
default: // idle
icon = "⚪";
message = "Component is idle. Click a button to change state.";
ariaLive = "off";}
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
transition: all 0.3s;
background-color: ${this.darkMode ? '#222' : '#fff'};
color: ${this.darkMode ? '#e0e0e0' : '#333'};
}
h2 {
margin-top: 0;
color: ${this.darkMode ? '#fff' : '#333'};
}
.container {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.icon {
font-size: 32px;
margin-right: 16px;
}
:host(:state(success)) .message {
color: #4caf50;
}
:host(:state(error)) .message {
color: #f44336;
}
.message {
font-size: 18px;
}
.buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 15px;
}
button {
padding: 8px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
filter: brightness(0.9);
}
button:active {
transform: translateY(1px);
}
button:focus {
outline: 2px solid #2196F3;
outline-offset: 2px;
}
button.idle {
background-color: ${this.darkMode ? '#555' : '#e0e0e0'};
color: ${this.darkMode ? '#fff' : '#333'};
}
button.loading {
background-color: #FFC107;
color: ${this.darkMode ? '#000' : '#333'};
}
button.success {
background-color: #4CAF50;
color: white;
}
button.error {
background-color: #f44336;
color: white;
}
button.theme-toggle {
background-color: ${this.darkMode ? '#777' : '#ddd'};
color: ${this.darkMode ? '#fff' : '#333'};
margin-top: 10px;
}
.settings {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 15px;
}
.settings input {
width: 4em;
}
.reset-info {
font-size: 14px;
margin: 10px 0;
color: ${this.darkMode ? '#aaa' : '#666'};
display: ${this.state === "idle" ? "none" : "block"};
}
.progress-container {
height: 4px;
width: 100%;
background-color: ${this.darkMode ? '#444' : '#eee'};
margin-bottom: 15px;
border-radius: 2px;
overflow: hidden;
display: ${this.state === "idle" ? "none" : "block"};
}
.progress-bar {
height: 100%;
width: 100%;
background-color: #2196F3;
transform-origin: left;
}
@keyframes progress {
0% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
.settings {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid ${this.darkMode ? '#444' : '#eee'};
}
.config {
display: flex;
align-items: center;
gap: 10px;
}
.config label {
font-size: 14px;
}
.config input {
width: 70px;
background-color: ${this.darkMode ? '#333' : '#fff'};
color: ${this.darkMode ? '#fff' : '#333'};
border: 1px solid ${this.darkMode ? '#555' : '#ddd'};
padding: 4px;
border-radius: 4px;
}
small {
color: ${this.darkMode ? '#aaa' : '#666'};
}
</style>
<div>
<h2>Custom State Pseudo-Class Demo</h2>
<p>This demonstrates how state changes can be exposed to CSS.</p>
<div class="progress-container" aria-hidden="true">
<div class="progress-bar"></div>
</div>
<div class="reset-info" aria-live="polite">
Will reset to idle in ${this.resetDuration / 1000} seconds
</div>
<div class="container">
<div class="icon" aria-hidden="true">${icon}</div>
<div class="message" aria-live="${ariaLive}">${message}</div>
</div>
<div class="buttons" role="group" aria-label="State control buttons">
<button class="idle" id="set-idle">Idle</button>
<button class="loading" id="set-loading">Loading</button>
<button class="success" id="set-success">Success</button>
<button class="error" id="set-error">Error</button>
</div>
<div class="settings">
<button id="toggle-theme" type="button">${this.darkMode ? 'Light' : 'Dark'} theme</button>
<label>
Reset after
<input id="reset-time" type="number" min="1" max="30" value="${this.resetDuration / 1000}">
seconds
</label>
</div>
<p>
<small>
This uses ElementInternals.states: the page styles
state-component:state(loading), and the component styles
:host(:state(success)) from inside its shadow root.
</small>
</p>
</div>
`;
// Add event listeners
this.shadowRoot.
querySelector("#set-idle").
addEventListener("click", () => {
this.setState("idle");
});
this.shadowRoot.
querySelector("#set-loading").
addEventListener("click", () => {
this.setState("loading");
});
this.shadowRoot.
querySelector("#set-success").
addEventListener("click", () => {
this.setState("success");
});
this.shadowRoot.
querySelector("#set-error").
addEventListener("click", () => {
this.setState("error");
});
// Add theme toggle listener
this.shadowRoot.
querySelector("#toggle-theme").
addEventListener("click", () => {
this.toggleDarkMode();
});
// Add reset duration input listener
this.shadowRoot.
querySelector("#reset-time").
addEventListener("change", e => {
const seconds = parseInt(e.target.value, 10);
if (seconds > 0) {
this.resetDuration = seconds * 1000;
// If there's an active timer, restart it with new duration
if (this.state !== "idle") {
this.setState(this.state);
}
}
});
}}
customElements.define("state-component", StateComponent);
Micro-Frontends: The Enterprise Adoption#
A micro-frontend splits one page into parts that separate teams build and ship. Web components give each part a boundary that does not depend on a framework. Adobe's Spectrum Web Components and Microsoft's FAST are published as web components, so any of these parts can use them whatever framework it is written in.
Community Momentum: Tools & Libraries#
Lit and Tooling#
Lit is the most widely used helper library in this post. It is a small base class that adds reactive properties and templates on top of custom elements. The Lit team also publishes experimental tools under the @lit-labs scope, such as @lit-labs/analyzer, which reads component source to describe it. Check Lit's own release notes for what is stable; this post makes no claims about future Lit versions.
Scoped Custom Element Registries: Solving Collisions#
The registry of custom element names is global: one tag name can only point to one class for the whole page. Scoped custom element registries let a shadow root have its own registry. The snippet below ran correctly in Chrome 156. As of 26 October 2026 it also ships in Chrome and Edge 146 and Safari 26, but not in a stable Firefox, so feature-detect it (typeof CustomElementRegistry) and have a plan for the browsers without it. It does not work for customized built-in elements.
const registry = new CustomElementRegistry();
registry.define('my-element', MyElement);
host.attachShadow({ mode: 'open', customElementRegistry: registry });
document.createElement('my-element', { customElementRegistry: registry });
Before browsers shipped it, the @webcomponents/scoped-custom-element-registry polyfill let teams try the pattern. Most apps never hit a tag-name clash, so this is a feature for the day two teams define <x-button> differently.
Below is an interactive example with two components that share one tag name. The demo checks for support first, so in a browser without scoped registries (Firefox, as of today) you will see a notice instead of the components.
---html
<div class="demo-container">
<header>
<h2>Scoped Custom Element Registries</h2>
<button class="theme-toggle" id="themeToggle">Toggle Theme</button>
</header>
<p>
This demo uses scoped custom element registries, built into Chrome, Edge and Safari.
The same tag name can have completely different implementations in different component scopes.
</p>
<component-a></component-a>
<component-b></component-b>
<component-c></component-c>
<div class="explanation">
<h3>About Scoped Custom Element Registries</h3>
<p>
Scoped Custom Element Registries are part of the future of web components,
allowing developers to:
</p>
<ul>
<li>Avoid name collisions between components</li>
<li>Use the same tag name for different implementations in different contexts</li>
<li>Create truly encapsulated component systems</li>
<li>Support easier component versioning</li>
<li>Enable better component composition patterns</li>
</ul>
<p>
Each component creates a <code>new CustomElementRegistry()</code>, defines its own
<code>custom-button</code> in it, and passes it to
<code>attachShadow({ customElementRegistry })</code>.
</p>
</div>
</div>
---css
:root {
--primary-color: #6200ee;
--secondary-color: #03dac6;
--background-color: #ffffff;
--text-color: #333333;
}
.dark-mode {
--primary-color: #bb86fc;
--secondary-color: #03dac6;
--background-color: #121212;
--text-color: #e0e0e0;
}
body {
font-family: system-ui, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
transition: background-color 0.3s ease;
}
.demo-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
component-a,
component-b,
component-c {
display: block;
margin: 20px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.theme-toggle {
background-color: var(--primary-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
---js
// Scoped Custom Element Registries: each component owns a CustomElementRegistry,
// passes it to attachShadow(), and creates elements from it. Nothing is added to
// the global customElements registry, so the same tag can mean different things.
const scopedRegistriesSupported = (() => {
try {
new CustomElementRegistry();
return true;
} catch {
return false;
}
})();
class ScopedElementsMixin {
constructor() {
this.registry = new CustomElementRegistry();
}
// Define a custom element in this component's scope
defineScopedElement(tagName, constructor) {
this.registry.define(tagName, constructor);
return constructor;
}
// Create an element from this scope's definitions
createScopedElement(tagName) {
return document.createElement(tagName, { customElementRegistry: this.registry });
}
// Check if a tag is defined in this scope
hasScopedElement(tagName) {
return this.registry.get(tagName) !== undefined;
}
}
// Component A with its own registry
class ComponentA extends HTMLElement {
constructor() {
super();
this.scopedElements = new ScopedElementsMixin();
this.attachShadow({ mode: "open", customElementRegistry: this.scopedElements.registry });
// Define version A of the custom button
class CustomButtonA extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
}
button:hover {
opacity: 0.9;
transform: translateY(-2px);
}
</style>
<button>Component A Button</button>
`;
const button = this.querySelector("button");
button.addEventListener("click", () => {
const event = new CustomEvent("component-action", {
bubbles: true,
composed: true,
detail: { source: "Component A", action: "clicked" } });
this.dispatchEvent(event);
});
}}
// Register in the scoped registry
this.scopedElements.defineScopedElement("custom-button", CustomButtonA);
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.card {
padding: 16px;
background-color: var(--background-color);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h3 {
margin-top: 0;
color: var(--primary-color);
}
.output {
margin-top: 10px;
font-style: italic;
color: var(--primary-color);
}
</style>
<div class="card">
<h3>Component A</h3>
<p>This component has its own scoped definition of custom-button:</p>
<div id="button-container"></div>
<div class="output" id="output-a"></div>
</div>
`;
// Create the scoped element
const button = this.scopedElements.createScopedElement("custom-button");
this.shadowRoot.querySelector("#button-container").appendChild(button);
// Listen for events from any component
document.addEventListener("component-action", e => {
const output = this.shadowRoot.querySelector("#output-a");
output.textContent = `Event received: ${e.detail.source} ${e.detail.action}`;
});
}}
// Component B with its own registry
class ComponentB extends HTMLElement {
constructor() {
super();
this.scopedElements = new ScopedElementsMixin();
this.attachShadow({ mode: "open", customElementRegistry: this.scopedElements.registry });
// Define version B of the custom button
class CustomButtonB extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
button {
background-color: var(--secondary-color);
color: black;
border: none;
padding: 12px 24px;
border-radius: 50px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
button:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
</style>
<button>Component B Button</button>
`;
const button = this.querySelector("button");
button.addEventListener("click", () => {
const event = new CustomEvent("component-action", {
bubbles: true,
composed: true,
detail: { source: "Component B", action: "clicked" } });
this.dispatchEvent(event);
});
}}
// Register in the scoped registry
this.scopedElements.defineScopedElement("custom-button", CustomButtonB);
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.card {
padding: 16px;
background-color: var(--background-color);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h3 {
margin-top: 0;
color: var(--secondary-color);
}
.output {
margin-top: 10px;
font-style: italic;
color: var(--secondary-color);
}
</style>
<div class="card">
<h3>Component B</h3>
<p>This component has its own scoped definition of custom-button:</p>
<div id="button-container"></div>
<div class="output" id="output-b"></div>
</div>
`;
// Create the scoped element
const button = this.scopedElements.createScopedElement("custom-button");
this.shadowRoot.querySelector("#button-container").appendChild(button);
// Listen for events from any component
document.addEventListener("component-action", e => {
const output = this.shadowRoot.querySelector("#output-b");
output.textContent = `Event received: ${e.detail.source} ${e.detail.action}`;
});
}}
// Component C with nested scoped elements
class ComponentC extends HTMLElement {
constructor() {
super();
this.scopedElements = new ScopedElementsMixin();
this.attachShadow({ mode: "open", customElementRegistry: this.scopedElements.registry });
// Define version C of the custom button
class CustomButtonC extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
button {
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}
button:hover {
transform: scale(1.05);
}
</style>
<button>Component C Button</button>
`;
const button = this.querySelector("button");
button.addEventListener("click", () => {
const event = new CustomEvent("component-action", {
bubbles: true,
composed: true,
detail: { source: "Component C", action: "clicked" } });
this.dispatchEvent(event);
});
}}
// Define a nested component with its own registry
class NestedComponent extends HTMLElement {
constructor() {
super();
this.nestedRegistry = new ScopedElementsMixin();
this.attachShadow({ mode: "open", customElementRegistry: this.nestedRegistry.registry });
// Define a unique version of custom-button for nested component
class NestedButton extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<style>
button {
background-color: transparent;
color: var(--text-color);
border: 2px solid currentColor;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
</style>
<button>Nested Button</button>
`;
const button = this.querySelector("button");
button.addEventListener("click", () => {
const event = new CustomEvent("nested-action", {
bubbles: true,
composed: true });
this.dispatchEvent(event);
});
}}
// Register nested button in its own registry
this.nestedRegistry.defineScopedElement("custom-button", NestedButton);
// Render nested component
this.shadowRoot.innerHTML = `
<style>
.nested {
margin: 10px;
padding: 10px;
border: 1px dashed var(--text-color);
border-radius: 4px;
}
h4 {
margin-top: 0;
font-size: 14px;
}
</style>
<div class="nested">
<h4>Nested Component Scope</h4>
<p>This nested component has its own scoped registry:</p>
<div id="nested-button-container"></div>
</div>
`;
// Create and append the nested button
const nestedButton = this.nestedRegistry.createScopedElement(
"custom-button");
this.shadowRoot.
querySelector("#nested-button-container").
appendChild(nestedButton);
// Listen for clicks on the nested button
this.shadowRoot.addEventListener("nested-action", () => {
const event = new CustomEvent("component-action", {
bubbles: true,
composed: true,
detail: { source: "Nested Component", action: "clicked" } });
this.dispatchEvent(event);
});
}}
// Register components in C's scope
this.scopedElements.defineScopedElement("custom-button", CustomButtonC);
this.scopedElements.defineScopedElement("nested-component", NestedComponent);
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.card {
padding: 16px;
background-color: var(--background-color);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h3 {
margin-top: 0;
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.component-registry {
margin-top: 15px;
font-size: 14px;
}
.output {
margin-top: 10px;
font-style: italic;
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: bold;
}
</style>
<div class="card">
<h3>Component C (Nested Components)</h3>
<p>This component demonstrates nested scoped registries:</p>
<div id="button-container"></div>
<div id="nested-container"></div>
<div class="component-registry">
<p>Registered elements in this scope:
<code>custom-button</code>, <code>nested-component</code>
</p>
</div>
<div class="output" id="output-c"></div>
</div>
`;
// Create the scoped elements
const button = this.scopedElements.createScopedElement("custom-button");
const nested = this.scopedElements.createScopedElement("nested-component");
this.shadowRoot.querySelector("#button-container").appendChild(button);
this.shadowRoot.querySelector("#nested-container").appendChild(nested);
// Listen for events from any component
document.addEventListener("component-action", e => {
const output = this.shadowRoot.querySelector("#output-c");
output.textContent = `Event received: ${e.detail.source} ${e.detail.action}`;
});
}}
// Register the components
if (scopedRegistriesSupported) {
customElements.define("component-a", ComponentA);
customElements.define("component-b", ComponentB);
customElements.define("component-c", ComponentC);
} else {
const notice = document.createElement("p");
notice.className = "explanation";
notice.textContent = "This browser does not support scoped custom element registries yet, so the components cannot be shown.";
document.querySelector("component-a").before(notice);
}
// Theme toggle functionality
const themeToggleButton = document.getElementById("themeToggle");
// Function to update button text based on current theme
function updateThemeButtonText() {
themeToggleButton.textContent = document.body.classList.contains("dark-mode") ?
"Light Theme" :
"Dark Theme";
}
// Check user's system preference for dark mode
function initializeThemeBasedOnPreference() {
// Check if user prefers dark mode
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").
matches;
// Set initial theme based on preference
if (prefersDarkMode) {
document.body.classList.add("dark-mode");
}
// Update button text to reflect initial state
updateThemeButtonText();
}
// Initialize theme when page loads
initializeThemeBasedOnPreference();
// Theme toggle button click handler
themeToggleButton.addEventListener("click", () => {
document.body.classList.toggle("dark-mode");
updateThemeButtonText();
});
Testing Gets Easier#
Web Test Runner can run component tests in real browsers, and Storybook has a web-components renderer that can read a component's description from a Custom Elements Manifest. Both reduce the need for framework-specific test setups.
On the Horizon: Features to Watch#
Manual Slot Assignment#
Normally the browser fills slots by matching slot="name" attributes. Manual assignment lets script decide instead. It ran in Chrome 156, and it is Baseline widely available (all three engines since March 2023).
const root = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
const slot = document.createElement('slot');
root.append(slot);
slot.assign(host.querySelector('h1')); // this child now fills the slot
You may see an element.attachSlot('header') call in older articles. That method does not exist (it is undefined in Chrome 156). Use slotAssignment: 'manual' and slot.assign().
@scope: Scoped Rules, Not Shadow DOM#
@scope limits where a CSS rule applies in a normal stylesheet. It became Baseline newly available in March 2026:
@scope (.card) {
h1 { color: red; } /* Only matches h1 elements inside .card */
}
It works in the light DOM and does not create or cross a shadow boundary (in the test, an h1 inside .card turned red and an h1 outside stayed black). Styling across a shadow boundary is done with CSS custom properties, ::part() and ::slotted(), all of which ship today.
WebAssembly Integration#
WebAssembly is compiled code that runs in the browser. A custom element can call into it, for example to do heavy number crunching while keeping a plain HTML interface. Tools such as wasm-bindgen let Rust code talk to JavaScript. This is a combination of existing features, not a new web component standard.
Check out a simulation of how we can bring WebAssembly into a web component below:
---html
<button id="dark-mode-toggle" class="dark-mode-toggle">🌙</button>
<h1>WebAssembly Component Integration</h1>
<p>
This component demonstrates how WebAssembly can be integrated with Web
Components for high-performance computation.
</p>
<wasm-component initial-value="5" wasm-url="math_ops.wasm"></wasm-component>
<div style="margin-top: 30px">
<h3>Component API Usage</h3>
<button id="external-set">Set Value to 42</button>
<button id="external-get">Get Current Value</button>
</div>
---css
body {
font-family: system-ui, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
transition: background-color 0.3s, color 0.3s;
}
body.dark-mode {
background-color: #121212;
color: #e0e0e0;
}
.dark-mode-toggle {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
cursor: pointer;
font-size: 24px;
}
h1,
h3 {
transition: color 0.3s;
}
body.dark-mode h1,
body.dark-mode h3 {
color: #e0e0e0;
}
---js
// Dark mode functionality
function initDarkMode() {
// Check for user preference
const prefersDarkMode =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
// Set initial mode based on preference
if (prefersDarkMode) {
document.body.classList.add("dark-mode");
}
// Add toggle listener
document.getElementById("dark-mode-toggle").addEventListener("click", () => {
document.body.classList.toggle("dark-mode");
updateDarkModeIcon();
});
updateDarkModeIcon();
}
function updateDarkModeIcon() {
const isDarkMode = document.body.classList.contains("dark-mode");
document.getElementById("dark-mode-toggle").textContent = isDarkMode
? "☀️"
: "🌙";
}
class WasmComponent extends HTMLElement {
static observedAttributes = ["initial-value", "wasm-url"];
constructor() {
super();
this.attachShadow({ mode: "open" });
this.counter = 0;
this.isLoading = true;
this.hasError = false;
this.errorMessage = "";
this.wasmModule = null;
this.MAX_VALUE = 1.9767639272283756e154; // Maximum allowed value
// Check if dark mode is active when component is created
this.isDarkMode = document.body.classList.contains("dark-mode");
this.render();
}
connectedCallback() {
// Listen for dark mode changes on the body
this._darkModeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
this.isDarkMode = document.body.classList.contains("dark-mode");
this.render();
}
});
});
this._darkModeObserver.observe(document.body, { attributes: true });
// Get initial values from attributes
if (this.hasAttribute("initial-value")) {
this.counter = parseInt(this.getAttribute("initial-value"), 10) || 0;
this.updateDisplay();
}
// Load the WASM module
const wasmUrl = this.getAttribute("wasm-url") || "math_ops.wasm";
this.loadWasmModule(wasmUrl);
}
disconnectedCallback() {
// Clean up observer when component is removed
if (this._darkModeObserver) {
this._darkModeObserver.disconnect();
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "initial-value" && this.shadowRoot) {
this.counter = parseInt(newValue, 10) || 0;
this.updateDisplay();
}
if (name === "wasm-url" && oldValue !== newValue && this.isConnected) {
this.isLoading = true;
this.hasError = false;
this.render();
this.loadWasmModule(newValue);
}
}
async loadWasmModule(url) {
try {
this.isLoading = true;
this.render();
await new Promise((resolve) => setTimeout(resolve, 1000));
this.wasmModule = {
exports: {
increment: (value) => {
const result = value + 1;
return result > this.MAX_VALUE ? this.MAX_VALUE : result;
},
decrement: (value) => value - 1,
square: (value) => {
const result = value * value;
return result > this.MAX_VALUE ? this.MAX_VALUE : result;
},
cube: (value) => {
const result = value * value * value;
return result > this.MAX_VALUE ? this.MAX_VALUE : result;
},
double: (value) => {
const result = value * 2;
return result > this.MAX_VALUE ? this.MAX_VALUE : result;
},
half: (value) => value / 2,
factorial: (value) => {
if (value <= 1) return 1;
let result = 1;
for (let i = 2; i <= value; i++) {
result *= i;
if (result > this.MAX_VALUE) return this.MAX_VALUE;
}
return result;
},
isPrime: (value) => {
if (value <= 1) return 0;
if (value <= 3) return 1;
if (value % 2 === 0 || value % 3 === 0) return 0;
let i = 5;
while (i * i <= value) {
if (value % i === 0 || value % (i + 2) === 0) return 0;
i += 6;
}
return 1;
}
}
};
this.isLoading = false;
this.hasError = false;
this.dispatchEvent(
new CustomEvent("wasm-loaded", {
bubbles: true,
composed: true,
detail: { success: true }
})
);
} catch (error) {
console.error("Failed to load WASM module:", error);
this.isLoading = false;
this.hasError = true;
this.errorMessage = error.message || "Failed to load WebAssembly module";
this.dispatchEvent(
new CustomEvent("wasm-error", {
bubbles: true,
composed: true,
detail: { error }
})
);
}
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
border: 1px solid ${this.isDarkMode ? "#9575cd" : "#673AB7"};
border-radius: 8px;
background-color: ${this.isDarkMode ? "#1e1e2f" : "#f8f5ff"};
color: ${this.isDarkMode ? "#e0e0e0" : "inherit"};
transition: background-color 0.3s, border-color 0.3s, color 0.3s;
}
h2 {
margin-top: 0;
color: ${this.isDarkMode ? "#9575cd" : "#673AB7"};
transition: color 0.3s;
}
.display {
font-size: 48px;
text-align: center;
margin: 20px 0;
font-weight: bold;
color: ${this.isDarkMode ? "#9575cd" : "#673AB7"};
transition: color 0.3s;
}
.buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-bottom: 15px;
}
button {
background-color: ${this.isDarkMode ? "#9575cd" : "#673AB7"};
color: ${this.isDarkMode ? "#121212" : "white"};
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s, color 0.2s;
}
button:hover {
background-color: ${this.isDarkMode ? "#b39ddb" : "#5e35b1"};
}
button:disabled {
background-color: ${this.isDarkMode ? "#4a4a4a" : "#b39ddb"};
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 20px;
color: ${this.isDarkMode ? "#9575cd" : "#673AB7"};
}
.loading::after {
content: "...";
animation: dots 1.5s infinite;
}
.error {
background-color: ${this.isDarkMode ? "#3c1f1f" : "#ffebee"};
border: 1px solid ${this.isDarkMode ? "#b71c1c" : "#f44336"};
color: ${this.isDarkMode ? "#f48fb1" : "#d32f2f"};
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
.note {
margin-top: 20px;
font-size: 14px;
color: ${this.isDarkMode ? "#aaa" : "#666"};
font-style: italic;
}
.prime-indicator {
font-size: 14px;
margin-top: 5px;
text-align: center;
font-weight: bold;
}
.prime-yes {
color: ${this.isDarkMode ? "#81c784" : "#4caf50"};
}
.prime-no {
color: ${this.isDarkMode ? "#e57373" : "#f44336"};
}
@keyframes dots {
0%, 20% { content: "."; }
40% { content: ".."; }
60%, 100% { content: "..."; }
}
</style>
<div>
<h2>Advanced WebAssembly Component Demo</h2>
<p>This demonstrates how a Web Component integrates with WebAssembly.</p>
${
this.isLoading
? `
<div class="loading">Loading WebAssembly module</div>
`
: this.hasError
? `
<div class="error">
<strong>Error:</strong> ${
this.errorMessage || "Failed to load WebAssembly module"
}
</div>
`
: `
<div class="display">${this.counter}</div>
<div class="prime-indicator ${
this.wasmModule?.exports.isPrime(this.counter)
? "prime-yes"
: "prime-no"
}">
${
this.wasmModule?.exports.isPrime(this.counter)
? "This is a prime number!"
: this.counter > 1
? "This is not a prime number"
: ""
}
</div>
<div class="buttons">
<button id="decrement" ?disabled="${
this.isLoading
}">Decrement</button>
<button id="increment" ?disabled="${
this.isLoading
}">Increment</button>
<button id="double" ?disabled="${
this.isLoading
}">Double</button>
<button id="half" ?disabled="${
this.isLoading
}">Half</button>
<button id="square" ?disabled="${
this.isLoading
}">Square</button>
<button id="cube" ?disabled="${
this.isLoading
}">Cube</button>
<button id="factorial" ?disabled="${
this.isLoading || this.counter > 12
}">Factorial</button>
<button id="reset" ?disabled="${
this.isLoading
}">Reset</button>
</div>
`
}
<p class="note">
${
this.isLoading
? "Loading WebAssembly module..."
: this.hasError
? "Failed to load WebAssembly module. Check console for details."
: "Note: This simulates WASM integration. In a production environment, these operations would be performed by actual WebAssembly code."
}
</p>
</div>
`;
if (!this.isLoading && !this.hasError) {
this.shadowRoot.querySelector("#increment").addEventListener("click", () => {
this.counter = this.wasmModule.exports.increment(this.counter);
this.updateDisplay();
this.checkMaxValueReached();
});
this.shadowRoot.querySelector("#decrement").addEventListener("click", () => {
this.counter = this.wasmModule.exports.decrement(this.counter);
this.updateDisplay();
});
this.shadowRoot.querySelector("#square").addEventListener("click", () => {
this.counter = this.wasmModule.exports.square(this.counter);
this.updateDisplay();
this.checkMaxValueReached();
});
this.shadowRoot.querySelector("#cube").addEventListener("click", () => {
this.counter = this.wasmModule.exports.cube(this.counter);
this.updateDisplay();
this.checkMaxValueReached();
});
this.shadowRoot.querySelector("#double").addEventListener("click", () => {
this.counter = this.wasmModule.exports.double(this.counter);
this.updateDisplay();
this.checkMaxValueReached();
});
this.shadowRoot.querySelector("#half").addEventListener("click", () => {
this.counter = this.wasmModule.exports.half(this.counter);
this.updateDisplay();
});
this.shadowRoot.querySelector("#factorial").addEventListener("click", () => {
if (this.counter <= 12) {
this.counter = this.wasmModule.exports.factorial(this.counter);
this.updateDisplay();
this.checkMaxValueReached();
}
});
this.shadowRoot.querySelector("#reset").addEventListener("click", () => {
this.counter = 0;
this.updateDisplay();
});
}
}
checkMaxValueReached() {
if (this.counter >= this.MAX_VALUE) {
console.warn("Maximum value reached");
const note = this.shadowRoot.querySelector(".note");
if (note) {
note.innerHTML =
'<strong style="color: #ff9800;">Warning: Maximum value reached. Some operations may be limited.</strong>';
setTimeout(() => {
note.innerHTML =
"Note: This simulates WASM integration. In a production environment, these operations would be performed by actual WebAssembly code.";
}, 3000);
}
}
}
updateDisplay() {
if (this.shadowRoot.querySelector(".display")) {
let displayText = this.counter;
if (this.counter > 1e15) {
displayText = this.counter.toExponential(2);
}
this.shadowRoot.querySelector(".display").textContent = displayText;
const primeIndicator = this.shadowRoot.querySelector(".prime-indicator");
if (primeIndicator) {
const isPrime = this.wasmModule.exports.isPrime(this.counter);
primeIndicator.className = `prime-indicator ${
isPrime ? "prime-yes" : "prime-no"
}`;
primeIndicator.textContent = isPrime
? "This is a prime number!"
: this.counter > 1
? "This is not a prime number"
: "";
}
const factorialBtn = this.shadowRoot.querySelector("#factorial");
if (factorialBtn) {
factorialBtn.disabled = this.counter > 12;
}
}
this.dispatchEvent(
new CustomEvent("value-changed", {
bubbles: true,
composed: true,
detail: { value: this.counter }
})
);
}
getValue() {
return this.counter;
}
setValue(value) {
const parsedValue = parseInt(value, 10) || 0;
this.counter = parsedValue > this.MAX_VALUE ? this.MAX_VALUE : parsedValue;
this.updateDisplay();
this.checkMaxValueReached();
}
}
customElements.define("wasm-component", WasmComponent);
document.addEventListener("DOMContentLoaded", initDarkMode);
document.getElementById("external-set").addEventListener("click", () => {
const component = document.querySelector("wasm-component");
component.setValue(42);
});
document.getElementById("external-get").addEventListener("click", () => {
const component = document.querySelector("wasm-component");
alert(`Current value: ${component.getValue()}`);
});
document
.querySelector("wasm-component")
.addEventListener("value-changed", (e) => {
console.log("Value changed:", e.detail.value);
});
document
.querySelector("wasm-component")
.addEventListener("wasm-loaded", (e) => {
console.log("WASM module loaded successfully");
});
Challenges That Remain#
- Frameworks. React passes unknown props to custom elements as attributes in version 18 and earlier (checked with 18.3.1 in the post on frameworks). React 19 changed this and is not covered here.
- Accessibility. Custom elements do not get built-in semantics.
ElementInternals.roleand the ARIA properties let a component declare its role;internals.role = 'button'worked in Chrome 156. A property namedaccessibleRoledoes not exist. - Learning. Documentation and examples spread across libraries remain the main barrier for newcomers.
Conclusion#
You can now tell which Web Component features are safe to use and which are not. Use ::part(), :state(), Declarative Shadow DOM (shadowrootmode), manual slotting, constructable stylesheets and custom events today. Check @scope against your oldest supported browser, and treat scoped registries as a feature for Chrome, Edge and Safari until Firefox ships it. When an article names a method you have not seen, search for it on MDN or run it in your console before building on it, as attachSlot shows.
For a next step, build a small component that uses one of the "ships today" rows from the table above. To follow standards work, the W3C Web Components Community Group publishes its discussions.
Comments require JavaScript.