Skip to content
Journey to Code

Journey to Web Components: From Fundamentals to Advanced UI Architecture

The Future of Web Components

Where Innovation Meets Standardization

· By · Updated · 8 min read

Cover reading "The Future of Web Components" with a roadmap from retrospective features to emerging and horizon platform features

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.

FeatureIn Chrome 156Status, Oct 2026
::part(): style what a component exposesWorksShips today
:state(): style a component by its own stateWorksBaseline newly available
Declarative Shadow DOM: shadowrootmodeWorksBaseline widely available
Manual slot assignment: slot.assign()WorksBaseline widely available
@scope: limit where a CSS rule appliesWorksBaseline newly available
Scoped registriesWorksChrome, Edge, Safari only
attachSlot()undefinedNot an API
accessibleRole propertyNot presentNot an API
Declarative custom elementsNot testableProposal

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 shadowroot attribute 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 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.

State Management in Web Components
---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.

Advanced State Management with Context
---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:

Declarative Shadow Dom
---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>&lt;my-card counter="1" &gt;
  &lt;template shadowrootmode="open"&gt;
    &lt;style&gt;
      :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;
      }
    &lt;/style&gt;

    &lt;div class="card-header"&gt;
      &lt;slot name="title"&gt;Default Title&lt;/slot&gt;
    &lt;/div&gt;
    &lt;div class="card-body"&gt;
      &lt;slot&gt;Default content&lt;/slot&gt;
    &lt;/div&gt;
    &lt;div class="counter"&gt;
      &lt;p&gt;Count: &lt;span id="count"&gt;0&lt;/span&gt;&lt;/p&gt;
      &lt;button id="decrement"&gt;-&lt;/button&gt;
      &lt;button id="increment"&gt;+&lt;/button&gt;
    &lt;/div&gt;
  &lt;/template&gt;

  &lt;h1 slot="title"&gt;Hello from Declarative Shadow DOM&lt;/h1&gt;
  &lt;p&gt;
    This card demonstrates Declarative Shadow DOM for server-side rendering.
  &lt;/p&gt;
&lt;/my-card&gt;</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.

Custom State Pseudo-Class Demo
---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.

Scoped Custom Element Registries
---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().

Which of these is NOT a real browser API?

@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.

What does @scope (.card) { h1 { color: red; } } do?

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:

WebAssembly Component Demo
---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.role and the ARIA properties let a component declare its role; internals.role = 'button' worked in Chrome 156. A property named accessibleRole does 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.