← Back to DevBytes

HTML Shadow DOM: Complete Guide

Introduction to Shadow DOM

Shadow DOM is one of the four core Web Component standards, alongside Custom Elements, HTML Templates, and ES Modules. It provides a mechanism for creating encapsulated DOM trees that are completely isolated from the main document's DOM. This means styles, scripts, and DOM structure inside a shadow tree do not leak out, and external styles and scripts do not bleed in — unless explicitly allowed.

Think of Shadow DOM as a private, self-contained document fragment attached to a host element. The browser's rendering engine treats this fragment as a separate scope, which enables true component-level encapsulation on the web platform without relying on third-party frameworks.

Why Shadow DOM Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before Shadow DOM, developers had to resort to elaborate CSS naming conventions like BEM, scoped styles via preprocessors, or iframes to achieve style isolation. These approaches were fragile, verbose, and often broke when third-party code was introduced. Shadow DOM solves this at the browser level with the following guarantees:

Core Concepts

The Shadow Tree and the Host

Every shadow DOM starts with a shadow host — a regular HTML element to which a shadow root is attached. The shadow root is the root node of the shadow tree, much like the document node is the root of the main DOM tree. The host element acts as the bridge between the light DOM and the shadow DOM.

Open vs. Closed Mode

When you attach a shadow root, you must specify its mode: open or closed. This choice has important implications:

<!-- Open mode: accessible via element.shadowRoot -->
<script>
  const host = document.querySelector('.my-component');
  const root = host.attachShadow({ mode: 'open' });
  // host.shadowRoot returns the shadow root
</script>

<!-- Closed mode: element.shadowRoot returns null -->
<script>
  const host = document.querySelector('.private-component');
  const root = host.attachShadow({ mode: 'closed' });
  // host.shadowRoot returns null
  // You must keep a reference to root yourself
</script>

In practice, open mode is recommended for most use cases. It enables debugging, allows library code to work with your components, and aligns with the principle of making the web inspectable. Reserve closed mode for cases where you have strong security or encapsulation requirements.

Creating and Attaching a Shadow Root

To create a shadow DOM, call attachShadow() on the host element. This method can only be called once per element — subsequent calls will throw an exception. Not all elements can host shadow DOM; the specification limits it to elements that make sense as component containers.

Elements Allowed to Host Shadow DOM

Elements like <input>, <img>, <video>, and most void elements cannot host shadow DOM because the browser already manages their internal structure.

Basic Example

<div id="host"></div>

<script>
  const host = document.getElementById('host');
  const shadowRoot = host.attachShadow({ mode: 'open' });

  // Populate the shadow tree
  shadowRoot.innerHTML = `
    <style>
      p { color: blue; font-family: sans-serif; }
      .container { padding: 10px; border: 1px solid #ccc; }
    </style>
    <div class="container">
      <p>This text lives inside the shadow DOM.</p>
    </div>
  `;

  // The paragraph is blue only within this shadow tree
  // Outer <p> elements on the page are unaffected
</script>

The styles defined in the <style> block apply only to elements inside the shadow tree. Any <p> tags in the main document remain completely unaffected.

Styling in Shadow DOM

Style isolation is the most impactful feature of Shadow DOM. Understanding how styles behave at the shadow boundary is essential for building robust components.

Rules of the Boundary

Styling the Host Element

The host element can be styled from both the outer document and from within the shadow tree using the :host pseudo-class. This creates a clean two-way styling interface:

<!-- In the outer document -->
<style>
  my-element {
    display: block;
    background: #f0f0f0;
    border-radius: 8px;
  }
  my-element.highlighted {
    box-shadow: 0 0 10px rgba(0,0,0,0.3);
  }
</style>

<my-element class="highlighted"></my-element>

<!-- Inside the shadow tree -->
<style>
  :host {
    display: block;       /* can be overridden from outside */
    padding: 16px;
  }
  :host(.highlighted) {
    border: 2px solid gold;
  }
  :host-context(body.dark-mode) {
    background: #333;
    color: white;
  }
</style>

The :host pseudo-class targets the host element from inside the shadow tree. :host(selector) applies when the host matches the given selector. :host-context(selector) applies when any ancestor of the host matches the selector — useful for theme-based styling.

CSS Custom Properties: The Styling Bridge

CSS custom properties (variables) pierce the shadow boundary automatically. This is the primary mechanism for creating themable, customizable components without exposing internal structure:

<!-- Outer document -->
<style>
  :root {
    --my-component-bg: #ffffff;
    --my-component-color: #333333;
    --my-component-padding: 16px;
  }
  .dark-theme {
    --my-component-bg: #1a1a1a;
    --my-component-color: #f0f0f0;
  }
</style>

<div class="dark-theme">
  <my-card></my-card>
</div>

<!-- Inside the shadow tree of <my-card> -->
<style>
  .card {
    background: var(--my-component-bg, #fff);
    color: var(--my-component-color, #000);
    padding: var(--my-component-padding, 16px);
    border-radius: 8px;
  }
</style>
<div class="card">
  <slot></slot>
</div>

This pattern allows consumers of your component to customize its appearance without ever touching the internal CSS. Always provide sensible defaults as the second argument to var().

Inheritable Styles

Certain CSS properties like color, font-family, font-size, line-height, and text-align are inherited by default. These will pass through the shadow boundary and affect text inside the shadow tree. To prevent this, explicitly set these properties on the shadow root's styles or on :host.

Slots: Composing Light DOM Content

Slots are the composition mechanism of Shadow DOM. They allow the light DOM content (children of the host element) to be projected into specific places within the shadow tree. This is similar to how native <slot> works in HTML, but with more power.

Named Slots and the Default Slot

A shadow tree can define multiple named slots using the name attribute, plus one unnamed default slot. Light DOM children are assigned to slots by matching their slot attribute to the slot's name. Children without a slot attribute go to the default slot.

<!-- The host in the light DOM -->
<user-card>
  <span slot="name">Alice Johnson</span>
  <span slot="title">Senior Developer</span>
  <img slot="avatar" src="alice.jpg" alt="Alice">
  <p>Alice is a full-stack developer with 10 years of experience.</p>
  <!-- This <p> has no slot attribute, goes to default slot -->
</user-card>

<!-- Inside the shadow tree -->
<style>
  .card { display: grid; grid-template-columns: 80px 1fr; gap: 8px; }
  .avatar-slot { grid-row: 1 / 3; }
  .name-slot { font-weight: bold; font-size: 1.2em; }
  .title-slot { color: #666; }
  .bio-slot { grid-column: 1 / 3; }
</style>
<div class="card">
  <div class="avatar-slot">
    <slot name="avatar">
      <!-- Fallback content if no avatar is provided -->
      <div class="default-avatar">👤</div>
    </slot>
  </div>
  <div class="name-slot"><slot name="name">Unknown User</slot></div>
  <div class="title-slot"><slot name="title">No title</slot></div>
  <div class="bio-slot">
    <slot><!-- Default slot: catches everything without a slot attribute --></slot>
  </div>
</div>

Slotted content remains in the light DOM — it is merely projected into the slot locations for rendering. This means the light DOM retains ownership of the slotted nodes, and events bubble through the light DOM tree. The slot elements themselves are just placeholders in the shadow tree.

Accessing Slotted Content

You can access the nodes assigned to a slot using the assignedNodes() method on the <slot> element. This is useful when you need to programmatically inspect or manipulate distributed content:

<script>
  class UserCard extends HTMLElement {
    constructor() {
      super();
      const shadow = this.attachShadow({ mode: 'open' });
      shadow.innerHTML = `
        <slot name="name"></slot>
        <slot name="avatar"></slot>
        <slot></slot>
      `;
    }

    connectedCallback() {
      const avatarSlot = this.shadowRoot.querySelector('slot[name="avatar"]');
      
      // Watch for slot changes
      avatarSlot.addEventListener('slotchange', (e) => {
        const nodes = avatarSlot.assignedNodes({ flatten: true });
        console.log('Avatar slot now contains:', nodes);
      });
    }
  }
  customElements.define('user-card', UserCard);
</script>

The slotchange event fires when the set of assigned nodes changes. The flatten: true option recursively resolves nested slots to their final assigned nodes.

Events in Shadow DOM

Event propagation behaves differently across the shadow boundary, and understanding this is critical for building interactive components.

Event Retargeting

When an event originates inside a shadow tree and bubbles up to the shadow root, the event's target is retargeted to appear as if it came from the host element. This preserves encapsulation — the outer document sees the host as the event source, not the internal element that was actually clicked.

<my-button></my-button>

<script>
  // Inside <my-button> shadow tree:
  const shadowRoot = host.attachShadow({ mode: 'open' });
  shadowRoot.innerHTML = `
    <button id="inner-btn">Click Me</button>
  `;
  shadowRoot.getElementById('inner-btn').addEventListener('click', () => {
    console.log('Inner button clicked');
  });

  // In the outer document:
  document.querySelector('my-button').addEventListener('click', (e) => {
    // e.target is <my-button>, NOT <button id="inner-btn">
    console.log('Target:', e.target); // Outputs: <my-button>
    console.log('Original target in shadow:', e.composedPath()[0]); // The real button
  });
</script>

Composed Events

Not all events cross the shadow boundary by default. Only composed events bubble out of the shadow tree. The composed property on the event determines this behavior:

To dispatch a custom event that crosses the shadow boundary, set composed: true:

<script>
  // Inside a shadow tree:
  this.dispatchEvent(new CustomEvent('item-selected', {
    bubbles: true,        // bubble up through the shadow tree
    composed: true,       // cross the shadow boundary into the light DOM
    detail: { itemId: 42 }
  }));

  // In the outer document, this event can now be heard:
  document.querySelector('my-list').addEventListener('item-selected', (e) => {
    console.log('Selected item:', e.detail.itemId); // 42
    // e.target is the host element due to retargeting
  });
</script>

Use e.composedPath() to get the full event path including nodes inside shadow trees. This is invaluable for debugging event flows.

Complete Component Example

Let's build a fully functional, encapsulated toggle switch component that demonstrates all the concepts covered so far:

<!-- Usage in HTML -->
<toggle-switch checked label="Wi-Fi"></toggle-switch>
<toggle-switch label="Bluetooth"></toggle-switch>

<script>
  class ToggleSwitch extends HTMLElement {
    // Observed attributes for reactive updates
    static get observedAttributes() {
      return ['checked', 'label', 'disabled'];
    }

    constructor() {
      super();
      // Attach open shadow root
      this._shadow = this.attachShadow({ mode: 'open' });
      
      // Define the shadow DOM structure
      this._shadow.innerHTML = `
        <style>
          /* Scoped styles — never leak outside */
          :host {
            display: inline-flex;
            align-items: center;
            gap: 10px;
            cursor: pointer;
            user-select: none;
            --track-bg: #ccc;
            --track-checked-bg: #4caf50;
            --thumb-bg: white;
            --thumb-checked-bg: white;
            --track-width: 50px;
            --track-height: 28px;
          }
          :host([disabled]) {
            opacity: 0.5;
            cursor: not-allowed;
          }
          .track {
            width: var(--track-width);
            height: var(--track-height);
            background: var(--track-bg);
            border-radius: 14px;
            position: relative;
            transition: background 0.3s ease;
          }
          .track.checked {
            background: var(--track-checked-bg);
          }
          .thumb {
            width: 22px;
            height: 22px;
            background: var(--thumb-bg);
            border-radius: 50%;
            position: absolute;
            top: 3px;
            left: 3px;
            transition: transform 0.3s ease;
            box-shadow: 0 1px 3px rgba(0,0,0,0.3);
          }
          .track.checked .thumb {
            transform: translateX(22px);
          }
          .label-slot {
            font-family: sans-serif;
            font-size: 14px;
            color: inherit;
          }
          /* Fallback styling for slotted content */
          ::slotted(*) {
            color: inherit;
          }
        </style>
        <div class="track" role="switch">
          <div class="thumb"></div>
        </div>
        <span class="label-slot">
          <slot name="label"></slot>
        </span>
      `;

      this._track = this._shadow.querySelector('.track');
      this._thumb = this._shadow.querySelector('.thumb');
    }

    connectedCallback() {
      this._updateState();
      // Listen for clicks on the host element
      this.addEventListener('click', this._toggle.bind(this));
    }

    disconnectedCallback() {
      this.removeEventListener('click', this._toggle.bind(this));
    }

    attributeChangedCallback(name, oldVal, newVal) {
      if (oldVal !== newVal) {
        this._updateState();
      }
    }

    // Getters and setters for the checked property
    get checked() {
      return this.hasAttribute('checked');
    }
    set checked(value) {
      if (value) {
        this.setAttribute('checked', '');
      } else {
        this.removeAttribute('checked');
      }
    }

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

    _updateState() {
      const isChecked = this.checked;
      const isDisabled = this.disabled;
      
      if (isChecked) {
        this._track.classList.add('checked');
        this._track.setAttribute('aria-checked', 'true');
      } else {
        this._track.classList.remove('checked');
        this._track.setAttribute('aria-checked', 'false');
      }
      
      if (isDisabled) {
        this._track.setAttribute('aria-disabled', 'true');
      } else {
        this._track.setAttribute('aria-disabled', 'false');
      }
    }

    _toggle(e) {
      if (this.disabled) return;
      this.checked = !this.checked;
      
      // Dispatch composed custom event
      this.dispatchEvent(new CustomEvent('toggle-change', {
        bubbles: true,
        composed: true,
        detail: { checked: this.checked }
      }));
    }
  }

  customElements.define('toggle-switch', ToggleSwitch);
</script>

This component demonstrates style encapsulation, CSS custom properties for theming, attribute observation, slots for label content, composed events, and accessibility through ARIA attributes — all protected within the shadow boundary.

Best Practices

1. Prefer Open Mode

Use mode: 'open' unless you have a compelling reason to use closed mode. Open mode enables debugging, testing, and inspection. Closed mode can make components opaque to developers and tools, which is rarely what you want.

2. Use CSS Custom Properties for Theming

Expose a set of CSS custom properties as your component's styling API. Document them clearly. Never require consumers to override internal selectors. This keeps your internal structure changeable without breaking consumers.

3. Keep Shadow Trees Small and Focused

A shadow tree should represent a single component's view. Avoid building entire applications inside one shadow root. Compose multiple components together in the light DOM.

4. Handle Slot Changes Gracefully

Use the slotchange event to react to content being added or removed from slots. This is especially important when your component needs to wire up event listeners or measure slotted content.

5. Dispatch Composed Events for Component Interactions

When your component needs to communicate with the outside world, dispatch custom events with bubbles: true and composed: true. This follows the principle of "events up, methods down."

6. Provide Sensible Fallbacks for Slots

Always include fallback content inside <slot> elements. This ensures your component renders usefully even when the consumer doesn't provide all expected slotted content.

7. Avoid Inline Event Handlers in Shadow DOM

Use addEventListener in your component class rather than onclick attributes in shadow DOM HTML. This keeps your component's logic centralized and easier to maintain.

8. Test Across Shadow Boundaries

When writing tests for shadow DOM components, remember that document.querySelector won't find elements inside shadow trees. Use host.shadowRoot.querySelector for open shadow roots, or test through your component's public API and events.

9. Consider Progressive Enhancement

When using Shadow DOM with custom elements, ensure your component provides useful content even if JavaScript fails to execute (server-side rendered content in the light DOM that gets enhanced when the shadow tree attaches).

10. Document Your Public API

Clearly document which attributes, properties, CSS custom properties, slots, and custom events your component exposes. The shadow boundary is your component's contract with the outside world — make it explicit.

Browser Support and Polyfills

Shadow DOM is supported in all modern browsers, including Chrome, Firefox, Safari, and Edge. For legacy browser support, you can use a polyfill like @webcomponents/webcomponentsjs, which includes polyfills for Shadow DOM, Custom Elements, and HTML Templates. However, as of 2024, most projects can confidently rely on native Shadow DOM support.

Conclusion

Shadow DOM is a foundational web platform capability that brings true encapsulation to browser-native components. By understanding the shadow boundary, mastering slots for composition, leveraging CSS custom properties for theming, and handling events correctly with composed dispatch, you can build robust, reusable, and maintainable UI components that work anywhere HTML works — without framework lock-in. The concepts covered in this guide form the bedrock of the Web Components ecosystem and will serve you well whether you're building a design system, a widget library, or a full application with vanilla JavaScript.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles