Step 04

Shadow DOM considerations

A shadow root gives an element its own DOM subtree with its own style scope: the page's CSS cannot reach in, and the component's CSS cannot leak out. That isolation is the whole feature, and it is also why shadow DOM is wrong for most application-level markup — SEO, form participation, and some accessibility tooling all expect one continuous document, not a boundary they cannot see past. This step covers what the boundary buys, what it costs, open versus closed roots, and the deliberate holes — ::part, ::slotted, and custom properties — that let a component author open specific gaps in it on purpose.

Learning Objectives

  • Understand Shadow DOM and light DOM and differences
  • Learn when to use Shadow DOM vs light DOM
  • Compare open vs closed shadow roots
  • Explore ideas to poke into the shadow with styles

What is Shadow DOM?

Shadow DOM provides encapsulation for web components by creating a separate DOM tree that's attached to an element but isolated from the main document tree. The main advantage of this is to protect the markup, style, and script (mostly events) from other parts of the page.

Core Features

  • Style Isolation: Styles don't leak out and only somewhat in (with effort)
  • DOM Encapsulation: Internal structure is hidden
  • Scoped Events: Some events are scoped to shadow tree
class MyElement extends HTMLElement { constructor() { super(); // Attach shadow root this.attachShadow({ mode: 'open' }); } connectedCallback() { // Add content to shadow root this.shadowRoot.innerHTML = ` <style> :host { display: block; padding: 1rem; } .content { color: blue; } </style> <div class="content"> Shadow DOM content </div> `; } } customElements.define('my-element', MyElement);

Benefits of Shadow DOM

✅ Style Encapsulation

CSS defined inside shadow DOM doesn't affect the rest of the page, and external CSS doesn't affect the shadow DOM.

/* Inside shadow DOM */ .button { color: blue; /* Won't conflict with page .button */ }

✅ Implementation Hiding

Internal DOM structure is hidden from external JavaScript, protecting implementation details.

// External code can't access internals element.querySelector('.internal'); // null // Must use shadowRoot element.shadowRoot.querySelector('.internal'); // ✓

✅ Composition via Slots

Slots enable flexible content projection while maintaining encapsulation.

<my-card> <span slot="title">Card Title</span> <p>Card content</p> </my-card>

✅ Scoped Events

Some events are retargeted to maintain encapsulation boundaries.

// Event target is the host element // not the internal element that was clicked element.addEventListener('click', (e) => { console.log(e.target); // <my-element> });

Limitations of Shadow DOM

⚠️ Important Limitations

Shadow DOM is powerful but comes with trade-offs. Understanding these limitations is crucial for making the right architectural decisions.

Key Limitations

  • Styling Complexity: External styling requires CSS custom properties or parts
  • Form Participation: Shadow DOM elements don't automatically participate in forms
  • Accessibility Challenges: Some screen readers and tools struggle with shadow boundaries
  • Global Styles: Utility classes and global styles don't penetrate shadow boundaries
  • Third-party Integration: Libraries that rely on global selectors won't work
  • Bot Considerations: Content in shadow DOM may not be indexed as effectively or worked with bots, though arguably most JavaScript driven content can suffer this problem as well


Example: External Styling Limitation

/* This external CSS won't style shadow DOM internals */ my-element .internal-class { color: red; /* ❌ Won't work */ } /* Must use CSS custom properties */ my-element { --text-color: red; /* ✓ Can be used inside if that variable is used within (var values bleed in) */ }

Open vs Closed Shadow DOM

When attaching a shadow root, you must specify whether it's open or closed.

✅ Open (Recommended)

constructor() { super(); this.attachShadow({ mode: 'open' }); } // Accessible from outside element.shadowRoot // Returns shadowRoot

Pros:

  • Debuggable in DevTools
  • Testable
  • Extensible
  • Standard practice

❌ Closed (Rarely Needed)

constructor() { super(); this.attachShadow({ mode: 'closed' }); } // Not accessible from outside element.shadowRoot // null

Cons:

  • Harder to debug
  • Can't test easily
  • Not actually secure
  • Limits flexibility
💡 Best Practice: Use mode: 'open' for almost all cases. Closed shadow DOM provides no real security and makes development harder.

Demo: Shadow DOM vs Light DOM Comparison

Compare the same message banner component implemented with and without Shadow DOM to see the practical differences.

Light DOM Implementation

class MessageBannerLight extends HTMLElement { connectedCallback() { const type = this.getAttribute('type') || 'info'; const content = this.textContent; // Renders to light DOM this.innerHTML = ` <div class="banner banner-${type}"> <span class="banner-icon">ℹ️</span> <span class="banner-content">${content}</span> </div> `; } } customElements.define('message-banner-light', MessageBannerLight);

Shadow DOM Implementation

class MessageBannerShadow extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { const type = this.getAttribute('type') || 'info'; // Renders to shadow DOM with encapsulated styles this.shadowRoot.innerHTML = ` <style> :host { display: block; margin: 1rem 0; } .banner { padding: 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; } .banner-info { background: #dbeafe; } .banner-success { background: #d1fae5; } .banner-warning { background: #fef3c7; } </style> <div class="banner banner-${type}"> <span class="banner-icon">ℹ️</span> <span class="banner-content"><slot></slot></span> </div> `; } } customElements.define('message-banner-shadow', MessageBannerShadow);

Live Comparison

Both banners render the same content through the same type attribute. The page in the frame below also carries one global rule targeting .banner-content — watch which banner it reaches.

When to Use Shadow DOM

Choosing between Shadow DOM and light DOM depends on your specific use case and requirements.

✅ Use Shadow DOM When:

  • Style isolation is critical
  • Building reusable component libraries
  • Need implementation hiding
  • Want slot-based composition
  • Building standalone widgets
  • Creating design system components

❌ Avoid Shadow DOM When:

  • Need deep CSS customization
  • Working with form elements
  • Require global styles to apply
  • Building application-specific components
  • SEO is critical (SSR)
  • Need maximum accessibility

Decision Guideline

Component Libraries: Use Shadow DOM for encapsulation and reusability.
Application Components: Use Light DOM for flexibility and global styling.
Mixed Approach: You can use both even in the same application. Always give yourself choices, even the sharpest API might be handy in some situations!

Styling Strategies for Shadow DOM

When using Shadow DOM, you need strategies to allow customization while maintaining encapsulation.

1. CSS Custom Properties (Variables)

/* Inside shadow DOM */ :host { background: var(--button-bg, #2563eb); color: var(--button-text, white); } /* External customization */ my-button { --button-bg: #10b981; --button-text: white; }

2. CSS Parts (::part)

<!-- Inside shadow DOM --> <div part="container"> <button part="button">Click</button> </div> /* External styling */ my-element::part(button) { background: red; }

3. Host Context (:host-context) — Chromium only

/* Style based on ancestor */ :host-context(.dark-theme) { background: #1f2937; color: white; } :host-context(.light-theme) { background: white; color: #1f2937; }
⚠️ Compatibility Warning: :host-context() is Chromium-only. Firefox and WebKit (Safari) have not shipped it and there is no cross-browser equivalent, so do not build theming on it. Pass the theme in explicitly instead — an attribute on the host that :host([theme="dark"]) matches, or an inherited custom property the page sets on an ancestor, both of which cross the boundary everywhere.

The three deliberate holes

Encapsulation cuts both ways. The page cannot reach in, which is the point, and is also the problem the first time somebody wants your button in their brand color. The platform's answer is not to weaken the boundary but to let the component open specific holes in it.

<!-- inside the shadow root: opt a node in to outside styling --> <button part="submit">Send</button> /* 1. ::part — style a node the component deliberately exposed */ my-form::part(submit) { background: rebeccapurple; } /* 2. ::slotted — style light-DOM children the page passed in */ :host ::slotted(p) { margin: 0; } /* 3. Custom properties — these cross the boundary by design */ :host { background: var(--my-form-bg, white); }

Custom properties are the real contract

Of the three, custom properties are the one to design on purpose. A documented set of --my-form-* properties is a theming API you can keep stable. ::part is narrower — it exposes a specific node, which means renaming or restructuring that node later is a breaking change.

Best Practices

Key Principles

  • Start without Shadow DOM: Add it only when needed
  • Use open shadow roots: Makes development and debugging easier
  • Investigate accessibility: Test with screen readers
  • Provide customization hooks: CSS custom properties for theming
  • Use slots for content: Enable flexible composition

Next Steps

In Step 5: Component lifecycle, we'll explore:

  • All five lifecycle callbacks, including the rarely-seen adoptedCallback
  • Constructor rules and restrictions
  • Resource management: timers, observers, and event listeners
  • Memory leak prevention, and one AbortController that removes every listener at once
  • Handling elements that connect and disconnect more than once