Skip to content
Journey to Code

Journey to Web Components: From Fundamentals to Advanced UI Architecture

Exploring the Shadow DOM

Enhancing Web Components with Encapsulated DOM Trees

· By · Updated · 6 min read

Cover reading "Shadow DOM" with a host element holding an open shadow root whose scoped styles document styles cannot reach

Introduction#

Ever dropped a widget into a page and watched the page's CSS wreck it? The Shadow DOM is the browser's fix: a component gets a private corner of the page, and the page's styles and scripts stay out of it.

By the end, we can attach a private shadow tree to an element, choose whether outside code can look inside, and say which page styles can and cannot reach it.

You only need two things from earlier: a custom element is a class that extends HTMLElement and is registered with customElements.define('my-tag', MyClass), and the "light DOM" is just the normal page markup you write between a component's tags, the part that is not hidden in a shadow tree.


Understanding the Shadow DOM#

The Shadow DOM allows developers to attach a hidden, self-contained DOM subtree—known as a shadow tree—to a host element. This encapsulation ensures that the component's internal DOM and CSS are isolated, preventing interference from external scripts and styles. The element to which the shadow tree is attached is called the shadow host. This approach promotes reusable components with predictable behavior.

This interactive example demonstrates one of the key benefits of Shadow DOM - style encapsulation. The component contains its own styles that don't affect (or get affected by) the main document. Try clicking the "Change Global Color" button, which changes the color of paragraphs outside the shadow DOM while leaving the component's internal paragraph untouched. Notice how the emoji is only added to the text inside the Shadow DOM, not to paragraphs in the light DOM.

Shadow DOM Style Encapsulation
---css
/* Default light mode styles */
:root {
  --background-color: #ffffff;
  --text-color: #333333;
  --link-color: #0066cc;
  /* Add other light mode variables as needed */
}

/* Dark mode styles */
@media (prefers-color-scheme: dark) {
  :root {
    --background-color: #121212;
    --text-color: #e0e0e0;
    --link-color: #80b3ff;
    /* Add other dark mode variables as needed */
  }
}

/* Apply the styles to html and body */
html, body {
  background-color: var(--background-color);
  color: var(--text-color);
  /* Add other common styles as needed */
}

/* Example of using the variables for other elements */
a {
  color: var(--link-color);
}
---js
class BasicShadowComponent extends HTMLElement {
  constructor() {
    super();
    // Attach a shadow root to the element
    const shadow = this.attachShadow({ mode: 'open' });

    // Create elements
    const wrapper = document.createElement('div');
    wrapper.setAttribute('class', 'component-wrapper');

    const info = document.createElement('p');
    info.textContent = 'This text is inside the shadow DOM!';

    // Create styles
    const style = document.createElement('style');
    style.textContent = `
      :host {
        display: block;
        margin: 20px 0;
        font-family: Arial, sans-serif;
      }
      
      /* Dark mode detection */
      @media (prefers-color-scheme: dark) {
        .component-wrapper {
          background-color: #2d2d2d;
          color: #e0e0e0;
          border: 1px solid #444;
        }
      }
      
      @media (prefers-color-scheme: light) {
        .component-wrapper {
          background-color: #f0f0f0;
          color: #333;
          border: 1px solid #ddd;
        }
      }
      
      .component-wrapper {
        padding: 15px;
        border-radius: 8px;
        transition: all 0.3s ease;
      }
      
      p {
        font-size: 16px;
        margin: 0;
      }
      
      /* This style won't affect paragraphs outside the shadow DOM */
      p::after {
        content: " 🎉";
      }
    `;

    // Attach elements to the shadow DOM
    shadow.appendChild(style);
    shadow.appendChild(wrapper);
    wrapper.appendChild(info);

    // Create a button to show the encapsulation working
    const button = document.createElement('button');
    button.textContent = 'Change Global Color';
    button.setAttribute('class', 'demo-button');
    button.addEventListener('click', () => {
      // This will only affect paragraph elements outside the shadow DOM
      const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16);
      document.getElementById('outside-paragraph').style.color = randomColor;
    });

    shadow.appendChild(button);
  }}


customElements.define('basic-shadow-component', BasicShadowComponent);

// Initialize for demo
document.addEventListener('DOMContentLoaded', () => {
  const container = document.createElement('div');
  container.innerHTML = `
    <h3>Shadow DOM Style Encapsulation</h3>
    <p id="outside-paragraph">This paragraph is outside the shadow DOM.</p>
    <basic-shadow-component></basic-shadow-component>
    <p>Another paragraph outside the shadow DOM.</p>
  `;
  document.body.appendChild(container);
});

Benefits of Using the Shadow DOM#

  • No collisions: a .title class inside the component and a .title class on the page cannot affect each other.

  • Drop-in reuse: the component looks the same on any page.

  • Easier debugging: a styling bug inside the component has to come from inside the component.

Poke the boundary yourself: change an attribute, fill a slot, and try to style the inside from the page.

Poke the shadow boundary

Change the card's tone attribute and the component restyles its own border from inside.

Add a page rule that colors the card's text and it recolors the slotted heading and paragraph, which live in the page's light DOM, but not the label the component draws inside its shadow tree: a selector from outside cannot cross the boundary.

Only what the component exposes gets through, such as the CSS custom property --card-accent and the shadow part ::part(footer).


Implementing the Shadow DOM#

To utilize the Shadow DOM in a web component, follow these steps:

  1. Attach a Shadow Root: Use the attachShadow method on the host element to create and attach a shadow root.

  2. Populate the Shadow DOM: Add elements and styles to the shadow root as needed.

Example:

// Create a class for the custom element
class MyComponent extends HTMLElement {
  constructor() {
    super();
    // Attach a shadow root to the host element
    const shadow = this.attachShadow({ mode: 'open' });

    // Create and style elements within the shadow DOM
    const wrapper = document.createElement('div');
    const style = document.createElement('style');
    style.textContent = `
      div {
        padding: 10px;
        background-color: #f0f0f0;
      }
    `;
    wrapper.textContent = 'This is a shadow DOM component';

    // Append elements to the shadow root
    shadow.appendChild(style);
    shadow.appendChild(wrapper);
  }
}

// Define the custom element
customElements.define('my-component', MyComponent);

In this example, a custom element <my-component> is defined with an attached shadow root. The shadow DOM contains a <div> element styled with encapsulated CSS, ensuring that styles do not leak into the main document.


Shadow DOM Modes: Open vs. Closed#

When attaching a shadow root, we can specify its mode:

  • Open: The shadow root is accessible via the shadowRoot property of the host element.

  • Closed: The shadow root is not exposed, and the shadowRoot property returns null.

Example:

// Attach an open shadow root
const openShadow = this.attachShadow({ mode: 'open' });
console.log(this.shadowRoot); // Outputs: shadowRoot object

// Attach a closed shadow root
const closedShadow = this.attachShadow({ mode: 'closed' });
console.log(this.shadowRoot); // Outputs: null

Pick open by default: it lets page scripts, tests and browser tools reach in with element.shadowRoot. Pick closed when you want the component's internals to be unreachable through that property, for example a widget that holds something page scripts should not poke at. Closed is a guard rail, not a security vault: the code that called attachShadow still holds the root, and closed mode makes testing and extending the component harder.

You run host.shadowRoot in the console and get null. Which mode was the shadow root attached with?

This example illustrates the practical difference between open and closed shadow DOM modes. The component on the top uses mode: 'open', allowing external JavaScript to access its internal elements. The bottom component uses mode: 'closed', preventing such access. Try clicking the buttons to see how JavaScript can successfully access content in the open shadow DOM but fails when attempting to access the closed shadow DOM. This demonstrates why you might choose one mode over the other depending on your security and encapsulation needs.

Open vs Closed Shadow DOM Demo
---css
/* Default light mode styles */
:root {
  --background-color: #ffffff;
  --text-color: #333333;
  --link-color: #0066cc;
  /* Add other light mode variables as needed */
}

/* Dark mode styles */
@media (prefers-color-scheme: dark) {
  :root {
    --background-color: #121212;
    --text-color: #e0e0e0;
    --link-color: #80b3ff;
    /* Add other dark mode variables as needed */
  }
}

/* Apply the styles to html and body */
html, body {
  background-color: var(--background-color);
  color: var(--text-color);
  /* Add other common styles as needed */
}

/* Example of using the variables for other elements */
a {
  color: var(--link-color);
}
---js
class OpenShadowComponent extends HTMLElement {
  constructor() {
    super();
    // Open mode allows external JavaScript to access the shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });

    // Apply styles and content
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          margin: 10px 0;
          padding: 15px;
          border-radius: 8px;
          font-family: Arial, sans-serif;
          transition: all 0.3s ease;
        }
        
        /* Dark mode support */
        @media (prefers-color-scheme: dark) {
          :host {
            background-color: #2d2d2d;
            color: #e0e0e0;
            border: 1px solid #444;
          }
        }
        
        @media (prefers-color-scheme: light) {
          :host {
            background-color: #f0f0f0;
            color: #333;
            border: 1px solid #ddd;
          }
        }
        
        h4 {
          margin-top: 0;
        }
      </style>
      <h4>Open Shadow DOM</h4>
      <div id="open-content">This shadow root is in 'open' mode</div>
    `;
  }}


class ClosedShadowComponent extends HTMLElement {
  constructor() {
    super();
    // Closed mode prevents external access to the shadow DOM
    const shadow = this.attachShadow({ mode: 'closed' });

    // Apply styles and content
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          margin: 10px 0;
          padding: 15px;
          border-radius: 8px;
          font-family: Arial, sans-serif;
          transition: all 0.3s ease;
        }
        
        /* Dark mode support */
        @media (prefers-color-scheme: dark) {
          :host {
            background-color: #2d2d2d;
            color: #e0e0e0;
            border: 1px solid #444;
          }
        }
        
        @media (prefers-color-scheme: light) {
          :host {
            background-color: #f0f0f0;
            color: #333;
            border: 1px solid #ddd;
          }
        }
        
        h4 {
          margin-top: 0;
        }
        
        div {
          position: relative;
        }
        
        div::after {
          content: " 🔒";
        }
      </style>
      <h4>Closed Shadow DOM</h4>
      <div id="secret-content">This shadow root is in 'closed' mode</div>
    `;
  }}


customElements.define('open-shadow-component', OpenShadowComponent);
customElements.define('closed-shadow-component', ClosedShadowComponent);

// Initialize the demo
document.addEventListener('DOMContentLoaded', () => {
  const container = document.createElement('div');
  container.innerHTML = `
    <h3>Open vs Closed Shadow DOM Demo</h3>
    <div id="components-container">
      <open-shadow-component id="open-component"></open-shadow-component>
      <closed-shadow-component id="closed-component"></closed-shadow-component>
    </div>
    <div id="demo-controls">
      <button id="access-open">Access Open Shadow DOM</button>
      <button id="access-closed">Try to Access Closed Shadow DOM</button>
      <div id="result-display" style="margin-top: 15px; padding: 10px; border-radius: 4px; min-height: 20px;">Results will appear here</div>
    </div>
  `;
  document.body.appendChild(container);

  // Apply dark mode to the result display
  const updateResultStyle = () => {
    const resultDisplay = document.getElementById('result-display');
    if (!resultDisplay) return;

    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
      resultDisplay.style.backgroundColor = '#2d2d2d';
      resultDisplay.style.color = '#e0e0e0';
      resultDisplay.style.border = '1px solid #444';
    } else {
      resultDisplay.style.backgroundColor = '#f0f0f0';
      resultDisplay.style.color = '#333';
      resultDisplay.style.border = '1px solid #ddd';
    }
  };

  updateResultStyle();

  // Handle media query changes
  if (window.matchMedia) {
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateResultStyle);
  }

  // Add event listeners to buttons
  document.getElementById('access-open').addEventListener('click', () => {
    const openComponent = document.getElementById('open-component');
    const resultDisplay = document.getElementById('result-display');

    try {
      // Try to access the open shadow DOM content
      const openContent = openComponent.shadowRoot.getElementById('open-content');
      resultDisplay.textContent = `Successfully accessed content: "${openContent.textContent}"`;
      resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#90ee90' : '#008000';
    } catch (error) {
      resultDisplay.textContent = `Error: ${error.message}`;
      resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#ff6b6b' : '#ff0000';
    }
  });

  document.getElementById('access-closed').addEventListener('click', () => {
    const closedComponent = document.getElementById('closed-component');
    const resultDisplay = document.getElementById('result-display');

    try {
      // Try to access the closed shadow DOM content
      if (closedComponent.shadowRoot) {
        const secretContent = closedComponent.shadowRoot.getElementById('secret-content');
        resultDisplay.textContent = `Content: "${secretContent.textContent}"`;
      } else {
        resultDisplay.textContent = "Cannot access shadowRoot: It returns null for closed mode";
        resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#ff6b6b' : '#ff0000';
      }
    } catch (error) {
      resultDisplay.textContent = `Error: ${error.message}`;
      resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#ff6b6b' : '#ff0000';
    }
  });
});

Styling Within the Shadow DOM#

Styles defined within the shadow DOM are scoped to that particular shadow tree, preventing them from affecting the global document and vice versa. This encapsulation ensures that components maintain consistent styling regardless of their environment.

Example:

const style = document.createElement('style');
style.textContent = `
  p {
    color: blue;
    font-size: 14px;
  }
`;
shadow.appendChild(style);

In this snippet, a <style> element is added to the shadow DOM, defining styles that apply only to elements within the shadow tree.

Now the failure, then the fix. A page rule p { color: red; } is global, yet it cannot reach a paragraph inside a shadow tree. One property of CSS still crosses the boundary: inherited properties such as color flow from the host element down into the shadow tree, as long as nothing inside overrides them.

Challenge. Run the block below: the page paragraph turns red and the component's paragraph stays black. Change one line (the TODO in the CSS) so the component's paragraph turns red too. Expected result: both paragraphs red.

Challenge: reach across the boundary
---html
<p>I am in the page.</p>
<my-box></my-box>
---css
/* TODO: this selector matches only page paragraphs.
   Change it to the component's tag name so the color is inherited inside. */
p { color: red; }
---js
class MyBox extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = '<p>I am inside the shadow tree.</p>';
  }
}
customElements.define('my-box', MyBox);

This example various techniques for styling components with Shadow DOM. It demonstrates how to style the host element (:host), how to apply context-specific styles (:host(.highlight)), and how to style content that gets slotted into your component (::slotted()). It also shows how to make your component theme-aware by using CSS variables. Try clicking the "Apply Custom Theme" buttons and the "Set Custom CSS Variables" button to see how styles can be dynamically updated while maintaining encapsulation.

Shadow DOM Styling Techniques
---css
/* Default light mode styles */
:root {
  --background-color: #ffffff;
  --text-color: #333333;
  --link-color: #0066cc;
  /* Add other light mode variables as needed */
}

/* Dark mode styles */
@media (prefers-color-scheme: dark) {
  :root {
    --background-color: #121212;
    --text-color: #e0e0e0;
    --link-color: #80b3ff;
    /* Add other dark mode variables as needed */
  }
}

/* Apply the styles to html and body */
html, body {
  background-color: var(--background-color);
  color: var(--text-color);
  /* Add other common styles as needed */
}

/* Example of using the variables for other elements */
a {
  color: var(--link-color);
}
---js
class StyledComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    // Render the initial component
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        /* Host styling */
        :host {
          display: block;
          margin: 20px 0;
          font-family: Arial, sans-serif;
        }
        
        /* Host context - changes when inside a specific container */
        :host(.highlight) {
          border: 2px dashed gold;
          padding: 8px;
          border-radius: 8px;
        }
        
        /* Component container */
        .component-container {
          padding: 15px;
          border-radius: 8px;
          transition: all 0.3s ease;
        }
        
        /* Dark mode detection */
        @media (prefers-color-scheme: dark) {
          .component-container {
            background-color: #2d2d2d;
            color: #e0e0e0;
            border: 1px solid #444;
          }
          .custom-button {
            background-color: #444;
            color: #e0e0e0;
            border: 1px solid #666;
          }
          .custom-button:hover {
            background-color: #555;
          }
        }
        
        @media (prefers-color-scheme: light) {
          .component-container {
            background-color: #f0f0f0;
            color: #333;
            border: 1px solid #ddd;
          }
          .custom-button {
            background-color: #e0e0e0;
            color: #333;
            border: 1px solid #ccc;
          }
          .custom-button:hover {
            background-color: #d0d0d0;
          }
        }
        
        /* Styling slotted content */
        ::slotted(h3) {
          margin-top: 0;
          color: var(--heading-color, #0066cc);
        }
        
        ::slotted(p) {
          margin-bottom: 0;
          font-style: italic;
        }
        
        .custom-button {
          padding: 8px 16px;
          margin-top: 10px;
          border-radius: 4px;
          cursor: pointer;
          transition: background-color 0.2s;
        }
      </style>
      
      <div class="component-container">
        <slot name="heading">Default heading</slot>
        <div class="content">
          <slot>Default content goes here</slot>
        </div>
        <button class="custom-button">Apply Custom Theme</button>
      </div>
    `;

    // Add event listener to the button
    this.shadowRoot.querySelector('.custom-button').addEventListener('click', () => {
      this.toggleCustomTheme();
    });
  }

  toggleCustomTheme() {
    // Toggle between themes by changing CSS variables on the host
    if (this.hasAttribute('theme')) {
      this.removeAttribute('theme');
      this.style.removeProperty('--heading-color');
    } else {
      this.setAttribute('theme', 'custom');
      // Generate a random color for the demo
      const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16);
      this.style.setProperty('--heading-color', randomColor);
    }
  }}


customElements.define('styled-component', StyledComponent);

// Initialize the demo
document.addEventListener('DOMContentLoaded', () => {
  const container = document.createElement('div');
  container.id = 'styling-demo-container';

  // Apply dark mode styles to the container
  const applyDarkModeStyles = () => {
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
      container.style.backgroundColor = '#1a1a1a';
      container.style.color = '#e0e0e0';
    } else {
      container.style.backgroundColor = '#ffffff';
      container.style.color = '#333333';
    }
  };

  applyDarkModeStyles();

  // Listen for dark mode changes
  if (window.matchMedia) {
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyDarkModeStyles);
  }

  container.innerHTML = `
    <h3>Shadow DOM Styling Techniques</h3>
    
    <p>Example 1: Basic component with slotted content</p>
    <styled-component>
      <h3 slot="heading">Custom Heading</h3>
      <p>This paragraph is styled by the component's shadow DOM</p>
    </styled-component>
    
    <p>Example 2: Using host context classes</p>
    <styled-component class="highlight">
      <h3 slot="heading">Highlighted Component</h3>
      <p>This component has a special highlight class</p>
    </styled-component>
    
    <div style="margin-top: 20px;">
      <button id="toggle-theme-vars">Set Custom CSS Variables</button>
    </div>
  `;

  document.body.appendChild(container);

  // Style the toggle button based on dark mode
  const toggleButton = document.getElementById('toggle-theme-vars');

  const styleToggleButton = () => {
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
      toggleButton.style.backgroundColor = '#444';
      toggleButton.style.color = '#e0e0e0';
      toggleButton.style.border = '1px solid #666';
      toggleButton.style.padding = '8px 16px';
      toggleButton.style.borderRadius = '4px';
      toggleButton.style.cursor = 'pointer';
    } else {
      toggleButton.style.backgroundColor = '#e0e0e0';
      toggleButton.style.color = '#333';
      toggleButton.style.border = '1px solid #ccc';
      toggleButton.style.padding = '8px 16px';
      toggleButton.style.borderRadius = '4px';
      toggleButton.style.cursor = 'pointer';
    }
  };

  styleToggleButton();

  if (window.matchMedia) {
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', styleToggleButton);
  }

  // Add event listener to toggle button
  toggleButton.addEventListener('click', () => {
    // Set global CSS variables that components can inherit
    const components = document.querySelectorAll('styled-component');
    components.forEach(component => {
      if (component.hasAttribute('global-theme')) {
        component.removeAttribute('global-theme');
        component.style.removeProperty('--heading-color');
      } else {
        component.setAttribute('global-theme', '');
        // Generate a random color
        const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16);
        component.style.setProperty('--heading-color', randomColor);
      }
    });
  });
});

Event handling across shadow boundaries requires special consideration. This example demonstrates the difference between composed and non-composed events. Events with composed: true can cross shadow boundaries and be caught by listeners in the main document, while events with composed: false remain confined within the shadow DOM. Try clicking both buttons and observe how only the composed event appears in the document-level event log. This pattern is essential for creating components that can communicate with their parent application.

Shadow DOM Event Propagation Demo
---css
/* Default light mode styles */
:root {
  --background-color: #ffffff;
  --text-color: #333333;
  --link-color: #0066cc;
  /* Add other light mode variables as needed */
}

/* Dark mode styles */
@media (prefers-color-scheme: dark) {
  :root {
    --background-color: #121212;
    --text-color: #e0e0e0;
    --link-color: #80b3ff;
    /* Add other dark mode variables as needed */
  }
}

/* Apply the styles to html and body */
html, body {
  background-color: var(--background-color);
  color: var(--text-color);
  /* Add other common styles as needed */
}

/* Example of using the variables for other elements */
a {
  color: var(--link-color);
}
---js
class EventComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          margin: 20px 0;
          font-family: Arial, sans-serif;
        }
        
        .component {
          padding: 15px;
          border-radius: 8px;
          transition: all 0.3s ease;
        }
        
        /* Dark mode detection */
        @media (prefers-color-scheme: dark) {
          .component {
            background-color: #2d2d2d;
            color: #e0e0e0;
            border: 1px solid #444;
          }
          button {
            background-color: #444;
            color: #e0e0e0;
            border: 1px solid #666;
          }
          button:hover {
            background-color: #555;
          }
          .event-log {
            background-color: #1a1a1a;
            border: 1px solid #444;
          }
        }
        
        @media (prefers-color-scheme: light) {
          .component {
            background-color: #f0f0f0;
            color: #333;
            border: 1px solid #ddd;
          }
          button {
            background-color: #e0e0e0;
            color: #333;
            border: 1px solid #ccc;
          }
          button:hover {
            background-color: #d0d0d0;
          }
          .event-log {
            background-color: #f8f8f8;
            border: 1px solid #ddd;
          }
        }
        
        h4 {
          margin-top: 0;
        }
        
        button {
          padding: 8px 16px;
          margin: 5px;
          border-radius: 4px;
          cursor: pointer;
          transition: background-color 0.2s;
        }
        
        .event-log {
          margin-top: 10px;
          padding: 10px;
          max-height: 100px;
          overflow-y: auto;
          font-family: monospace;
          font-size: 14px;
          border-radius: 4px;
        }
        
        .composed-event {
          color: #4caf50;
        }
        
        .non-composed-event {
          color: #f44336;
        }
      </style>
      
      <div class="component">
        <h4>Shadow DOM Events</h4>
        
        <div>
          <button id="composed-event">Dispatch Composed Event</button>
          <button id="non-composed-event">Dispatch Non-Composed Event</button>
        </div>
        
        <div class="event-log" id="event-log">
          <!-- Event logs will appear here -->
        </div>
      </div>
    `;

    // Add event listeners to buttons
    this.shadowRoot.getElementById('composed-event').addEventListener('click', () => {
      this.dispatchComposedEvent();
    });

    this.shadowRoot.getElementById('non-composed-event').addEventListener('click', () => {
      this.dispatchNonComposedEvent();
    });
  }

  // Dispatches an event that crosses shadow boundaries
  dispatchComposedEvent() {
    const customEvent = new CustomEvent('shadow-event', {
      bubbles: true,
      composed: true, // This allows the event to cross shadow boundaries
      detail: { message: 'Event from shadow DOM (composed)' } });


    this.logEvent('Dispatched composed event');
    this.dispatchEvent(customEvent);
  }

  // Dispatches an event that stays within shadow boundaries
  dispatchNonComposedEvent() {
    const customEvent = new CustomEvent('shadow-event', {
      bubbles: true,
      composed: false, // This prevents the event from crossing shadow boundaries
      detail: { message: 'Event from shadow DOM (non-composed)' } });


    this.logEvent('Dispatched non-composed event');
    this.dispatchEvent(customEvent);
  }

  logEvent(message, className) {
    const logElement = this.shadowRoot.getElementById('event-log');
    const entry = document.createElement('div');
    entry.textContent = `${message}`;
    if (className) {
      entry.className = className;
    }
    logElement.appendChild(entry);
    // Auto-scroll to bottom
    logElement.scrollTop = logElement.scrollHeight;
  }}


customElements.define('event-component', EventComponent);

// Initialize the demo
document.addEventListener('DOMContentLoaded', () => {
  const container = document.createElement('div');
  container.innerHTML = `
    <h3>Shadow DOM Event Propagation Demo</h3>
    <div id="event-demo-container">
      <event-component id="event-component"></event-component>
      <div id="global-event-log" style="margin-top: 20px; padding: 10px; border-radius: 4px; font-family: monospace; max-height: 100px; overflow-y: auto;">
        <div><strong>Document-level event log:</strong></div>
      </div>
    </div>
  `;

  document.body.appendChild(container);

  // Apply dark mode styles to the global event log
  const updateGlobalLogStyle = () => {
    const globalLog = document.getElementById('global-event-log');
    if (!globalLog) return;

    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
      globalLog.style.backgroundColor = '#1a1a1a';
      globalLog.style.color = '#e0e0e0';
      globalLog.style.border = '1px solid #444';
    } else {
      globalLog.style.backgroundColor = '#f8f8f8';
      globalLog.style.color = '#333';
      globalLog.style.border = '1px solid #ddd';
    }
  };

  updateGlobalLogStyle();

  // Listen for dark mode changes
  if (window.matchMedia) {
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateGlobalLogStyle);
  }

  // Listen for events at the document level
  document.addEventListener('shadow-event', event => {
    const globalLog = document.getElementById('global-event-log');
    const entry = document.createElement('div');
    entry.textContent = `Received: ${event.detail.message}`;
    entry.style.color = '#4caf50';
    globalLog.appendChild(entry);

    // Auto-scroll to bottom
    globalLog.scrollTop = globalLog.scrollHeight;
  });
});

Practical Application: Building a Reusable Component#

Here is the whole idea in one component: an interactive card. This one is for skimming; the code is long, so run it and click around rather than reading every line. A few names in it are new. connectedCallback is a method the browser calls when the element is added to the page. observedAttributes lists the attributes the element wants to hear about, and the browser then calls attributeChangedCallback when one changes. ::slotted() is the CSS selector for light DOM children placed into a <slot>. Click on a card header to expand/collapse its content, and click on the colored circle to cycle through different themes. Notice how the default theme respects the user's dark mode preference while custom themes maintain their specific styling. This demonstrates how Shadow DOM helps create self-contained, reusable UI components that can be easily integrated into any application.

Interactive Card Component with Shadow DOM
---css
/* Default light mode styles */
:root {
	--background-color: #ffffff;
	--text-color: #333333;
	--link-color: #0066cc;
	/* Add other light mode variables as needed */
}

/* Dark mode styles */
@media (prefers-color-scheme: dark) {
	:root {
		--background-color: #121212;
		--text-color: #e0e0e0;
		--link-color: #80b3ff;
		/* Add other dark mode variables as needed */
	}
}

/* Apply the styles to html and body */
html,
body {
	background-color: var(--background-color);
	color: var(--text-color);
	/* Add other common styles as needed */
}

/* Example of using the variables for other elements */
a {
	color: var(--link-color);
}
---js
class ShadowCard extends HTMLElement {
  constructor() {
    super();
    // Attach open shadow root
    this.attachShadow({ mode: "open" });

    // Initialize properties
    this._expanded = false;
    this._theme = "default";

    // Render the component
    this.render();
  }

  // Attributes to observe for changes
  static get observedAttributes() {
    return ["title", "theme"];
  }

  // Handle attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;

    if (name === "title") {
      this.updateTitle(newValue);
    } else if (name === "theme") {
      this._theme = newValue || "default";
      this.updateTheme();
    }
  }

  // Connected callback - component added to DOM
  connectedCallback() {
    // Add event listeners
    const boundToggleExpand = this.toggleExpand.bind(this);
    const boundCycleTheme = this.cycleTheme.bind(this);

    this.shadowRoot.
    querySelector(".card-header").
    addEventListener("click", boundToggleExpand);
    this.shadowRoot.
    querySelector(".theme-toggle").
    addEventListener("click", e => {
      e.stopPropagation();
      boundCycleTheme();
    });

    // Store bound methods for later removal
    this._boundToggleExpand = boundToggleExpand;
    this._boundCycleTheme = boundCycleTheme;
  }

  // Disconnected callback - component removed from DOM
  disconnectedCallback() {
    // Clean up event listeners
    this.shadowRoot.
    querySelector(".card-header").
    removeEventListener("click", this._boundToggleExpand);
    this.shadowRoot.
    querySelector(".theme-toggle").
    removeEventListener("click", this._boundCycleTheme);
  }

  // Toggle expand/collapse state
  toggleExpand() {
    this._expanded = !this._expanded;
    const content = this.shadowRoot.querySelector(".card-content");
    const chevron = this.shadowRoot.querySelector(".chevron");

    if (this._expanded) {
      content.style.maxHeight = `${content.scrollHeight}px`;
      chevron.classList.add("rotated");
    } else {
      content.style.maxHeight = "0";
      chevron.classList.remove("rotated");
    }
  }

  // Cycle through available themes
  cycleTheme() {
    const themes = ["default", "primary", "success", "danger"];
    const currentIndex = themes.indexOf(this._theme);
    const nextIndex = (currentIndex + 1) % themes.length;
    this._theme = themes[nextIndex];

    this.setAttribute("theme", this._theme);
    this.updateTheme();
  }

  // Update the card title
  updateTitle(title) {
    const titleElement = this.shadowRoot.querySelector(".card-title");
    if (titleElement) {
      titleElement.textContent = title || "Untitled Card";
    }
  }

  // Update the card theme
  updateTheme() {
    const card = this.shadowRoot.querySelector(".shadow-card");

    // Remove all previous theme classes
    card.classList.remove(
    "theme-default",
    "theme-primary",
    "theme-success",
    "theme-danger");


    // Add new theme class
    card.classList.add(`theme-${this._theme}`);
  }

  // Render the component
  render() {
    this.shadowRoot.innerHTML = `
        <style>
          /* Base styles */
          :host {
            display: block;
            margin: 20px 0;
            font-family: Arial, sans-serif;
          }
          
          .shadow-card {
            border-radius: 8px;
            overflow: hidden;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
            transition: all 0.3s ease;
          }
          
          .card-header {
            padding: 15px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            cursor: pointer;
            user-select: none;
          }
          
          .card-title {
            margin: 0;
            font-size: 18px;
            font-weight: bold;
          }
          
          .card-actions {
            display: flex;
            align-items: center;
          }
          
          .theme-toggle {
            margin-right: 10px;
            width: 18px;
            height: 18px;
            border-radius: 50%;
            cursor: pointer;
            border: 2px solid rgba(255, 255, 255, 0.5);
            transition: transform 0.2s ease;
          }
          
          .theme-toggle:hover {
            transform: scale(1.2);
          }
          
          .chevron {
            width: 18px;
            height: 18px;
            transition: transform 0.3s ease;
            display: flex;
            align-items: center;
            justify-content: center;
          }
          
          .chevron::after {
            content: "⌵";
            font-size: 18px;
            display: block;
          }
          
          .chevron.rotated {
            transform: rotate(180deg);
          }
          
          .card-content {
            padding: 0 15px;
            max-height: 0;
            overflow: hidden;
            transition: max-height 0.3s ease;
          }
          
          .content-inner {
            padding-bottom: 15px;
          }
          
          /* Theme styles */
          /* Dark mode detection for default theme */
          @media (prefers-color-scheme: dark) {
            .theme-default .card-header {
              background-color: #2d2d2d;
              color: #e0e0e0;
            }
            
            .theme-default .theme-toggle {
              background-color: #555;
            }
            
            .theme-default {
              border: 1px solid #444;
            }
            
            .theme-default .card-content {
              background-color: #1a1a1a;
              color: #e0e0e0;
            }
          }
          
          @media (prefers-color-scheme: light) {
            .theme-default .card-header {
              background-color: #f0f0f0;
              color: #333;
            }
            
            .theme-default .theme-toggle {
              background-color: #ccc;
            }
            
            .theme-default {
              border: 1px solid #ddd;
            }
            
            .theme-default .card-content {
              background-color: #ffffff;
              color: #333;
            }
          }
          
          /* Custom themes (not affected by dark mode) */
          .theme-primary .card-header {
            background-color: #4a6da7;
            color: white;
          }
          
          .theme-primary .theme-toggle {
            background-color: #2c4a7c;
          }
          
          .theme-primary {
            border: 1px solid #4a6da7;
          }
          
          .theme-primary .card-content {
            background-color: #f0f4f8;
            color: #333;
          }
          
          .theme-success .card-header {
            background-color: #4caf50;
            color: white;
          }
          
          .theme-success .theme-toggle {
            background-color: #2e7d32;
          }
          
          .theme-success {
            border: 1px solid #4caf50;
          }
          
          .theme-success .card-content {
            background-color: #f1f8e9;
            color: #333;
          }
          
          .theme-danger .card-header {
            background-color: #f44336;
            color: white;
          }
          
          .theme-danger .theme-toggle {
            background-color: #c62828;
          }
          
          .theme-danger {
            border: 1px solid #f44336;
          }
          
          .theme-danger .card-content {
            background-color: #fef8f6;
            color: #333;
          }
          
          /* Animation for hovering */
          .shadow-card:hover {
            box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
            transform: translateY(-2px);
          }
        </style>
        
        <div class="shadow-card theme-default">
          <div class="card-header">
            <h3 class="card-title">${
    this.getAttribute("title") || "Untitled Card"
    }</h3>
            <div class="card-actions">
              <div class="theme-toggle" title="Change theme"></div>
              <div class="chevron"></div>
            </div>
          </div>
          <div class="card-content">
            <div class="content-inner">
              <slot></slot>
            </div>
          </div>
        </div>
      `;
  }}


customElements.define("shadow-card", ShadowCard);

// Initialize the demo
document.addEventListener("DOMContentLoaded", () => {
  try {
    console.log("Initializing Shadow Card component...");
    const container = document.createElement("div");
    container.id = "shadow-card-container";

    // Apply some basic dark mode aware styles to the container
    const applyContainerStyles = () => {
      if (
      window.matchMedia &&
      window.matchMedia("(prefers-color-scheme: dark)").matches)
      {
        container.style.backgroundColor = "#1a1a1a";
        container.style.color = "#e0e0e0";
      } else {
        container.style.backgroundColor = "#ffffff";
        container.style.color = "#333333";
      }
    };

    applyContainerStyles();

    // Listen for dark mode changes
    if (window.matchMedia) {
      window.
      matchMedia("(prefers-color-scheme: dark)").
      addEventListener("change", applyContainerStyles);
    }

    container.innerHTML = `
        <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
          <h3>Interactive Card Component with Shadow DOM</h3>
          <p>Click on a card header to expand/collapse. Click on the colored circle to change the theme.</p>
          
          <shadow-card title="What is Shadow DOM?">
            <p>Shadow DOM provides encapsulation for DOM and CSS in a web component.</p>
            <p>It allows you to include a subtree of DOM elements inside your component that is rendered separately from the main document DOM.</p>
          </shadow-card>
          
          <shadow-card title="Key Benefits" theme="primary">
            <ul>
              <li><strong>Style Encapsulation:</strong> CSS styles defined inside the shadow DOM don't leak out</li>
              <li><strong>DOM Encapsulation:</strong> Main document JavaScript can't easily access shadow DOM elements</li>
              <li><strong>Composition:</strong> Create clean, reusable components with their own internal structure</li>
            </ul>
          </shadow-card>
          
          <shadow-card title="Getting Started" theme="success">
            <pre style="overflow-x: auto; white-space: pre-wrap; word-break: break-word;">
    const shadow = element.attachShadow({mode: 'open'});
    shadow.innerHTML = \`
      <style>p { color: red; }</style>
      <p>Hello Shadow DOM!</p>
    \`;
            </pre>
            <p>This creates a shadow root and adds content to it with encapsulated styles.</p>
          </shadow-card>
        </div>
      `;

    document.body.appendChild(container);
    console.log("Shadow Card component initialized and added to DOM");

    // Verify the components are rendered correctly
    setTimeout(() => {
      const cards = document.querySelectorAll("shadow-card");
      console.log(`Found ${cards.length} shadow-card components on the page`);
    }, 100);
  } catch (error) {
    console.error("Failed to initialize Shadow Card component:", error);
  }
});

// Add a fallback initialization in case DOMContentLoaded already fired
if (
document.readyState === "complete" ||
document.readyState === "interactive")
{
  console.log(
  "DOM already loaded, initializing Shadow Card component immediately");

  setTimeout(() => {
    const event = new Event("DOMContentLoaded");
    document.dispatchEvent(event);
  }, 1);
}

Conclusion#

You can now give an element a private tree with attachShadow, decide with open or closed whether shadowRoot exposes it, and predict what the page's CSS can touch: the host, slotted light DOM children and inherited properties, but not the inner elements.

What we built by hand with document.createElement gets tedious fast. The <template> and <slot> elements let you describe a component's inside as plain HTML once, and let each use of the component fill in its own content.

Comments require JavaScript.