Shadow DOM
Shadow DOM is a browser technology that provides DOM encapsulation and style scoping for web components. It creates a hidden, isolated DOM tree attached to an element, separate from the main document's DOM tree. This isolation prevents style conflicts, DOM collisions, and unintended interactions between component internals and the host page.
The shadow DOM lives inside a shadow root, which is attached to a host element. The host element acts as the public interface, while the shadow tree contains the internal implementation. CSS rules defined inside the shadow tree do not leak out, and external styles do not penetrate in — unless explicitly designed to do so.
info
A shadow tree is created by calling element.attachShadow({ mode: 'open' | 'closed' }). The method returns the shadow root, which is a DocumentFragment that becomes the root of the shadow tree. Once attached, you populate it using standard DOM methods.
The mode option controls external access to the shadow root:
| Mode | Description | Access |
|---|---|---|
| open | Shadow root is accessible via element.shadowRoot | element.shadowRoot returns the root |
| closed | Shadow root is inaccessible from outside | element.shadowRoot returns null |
| 1 | // Open mode — default for most use cases |
| 2 | const host = document.getElementById('host'); |
| 3 | const shadowRoot = host.attachShadow({ mode: 'open' }); |
| 4 | console.log(host.shadowRoot); // ShadowRoot object |
| 5 | |
| 6 | // Closed mode — stricter isolation |
| 7 | const closedHost = document.getElementById('closed'); |
| 8 | const closedRoot = closedHost.attachShadow({ mode: 'closed' }); |
| 9 | console.log(closedHost.shadowRoot); // null |
| 10 | |
| 11 | // Populate the shadow tree |
| 12 | shadowRoot.innerHTML = ` |
| 13 | <style> |
| 14 | p { color: #00FF41; font-family: monospace; } |
| 15 | </style> |
| 16 | <p>Inside the shadow DOM</p> |
| 17 | `; |
| 18 | |
| 19 | // Append children programmatically |
| 20 | const div = document.createElement('div'); |
| 21 | div.textContent = 'Also inside shadow DOM'; |
| 22 | shadowRoot.appendChild(div); |
warning
Encapsulation is the primary reason to use Shadow DOM. It provides two distinct types of isolation: style scoping and DOM isolation.
Style Scoping
CSS rules defined inside a shadow tree apply only to elements within that tree. They cannot leak out to the host page. Conversely, global styles from the host page do not affect shadow DOM content. This eliminates CSS conflicts, specificity wars, and the need for complex naming conventions like BEM.
| 1 | <style> |
| 2 | /* Global styles — do NOT affect shadow content */ |
| 3 | p { color: red; font-size: 20px; } |
| 4 | .card { background: blue; } |
| 5 | </style> |
| 6 | |
| 7 | <div id="host"></div> |
| 8 | |
| 9 | <script> |
| 10 | const host = document.getElementById('host'); |
| 11 | const root = host.attachShadow({ mode: 'open' }); |
| 12 | root.innerHTML = ` |
| 13 | <style> |
| 14 | /* Shadow-scoped styles — isolated from global */ |
| 15 | p { color: #00FF41; font-size: 14px; } |
| 16 | .card { background: #111; border: 1px solid #333; } |
| 17 | </style> |
| 18 | <p class="card">This text is green, not red!</p> |
| 19 | `; |
| 20 | </script> |
DOM Isolation
The shadow tree is completely separate from the document's light DOM. Methods like document.querySelector cannot reach into the shadow tree. IDs inside the shadow tree are scoped to that tree, preventing ID collisions. Event retargeting ensures that events fired inside the shadow tree appear to originate from the host element.
| 1 | // DOM isolation in action |
| 2 | const host = document.createElement('div'); |
| 3 | const root = host.attachShadow({ mode: 'open' }); |
| 4 | root.innerHTML = ` |
| 5 | <div id="internal"> |
| 6 | <p>Hidden from outside queries</p> |
| 7 | </div> |
| 8 | `; |
| 9 | |
| 10 | document.body.appendChild(host); |
| 11 | |
| 12 | // These return null — the shadow tree is isolated |
| 13 | console.log(document.querySelector('#internal')); // null |
| 14 | console.log(document.querySelector('p')); // null (if only inside shadow) |
| 15 | |
| 16 | // Access via the shadow root |
| 17 | console.log(host.shadowRoot.querySelector('#internal')); // found! |
Shadow DOM uses the <slot> element to compose light DOM children with the shadow tree. Slots act as insertion points where the host element's children are rendered within the shadow template. This enables flexible, reusable components that can accept arbitrary content.
Default Slot
A <slot> without a name attribute is the default slot. It renders all child nodes that are not assigned to a named slot.
| 1 | <!-- Component definition --> |
| 2 | <template id="my-card"> |
| 3 | <style> |
| 4 | .card { border: 1px solid #333; border-radius: 6px; padding: 16px; background: #111; } |
| 5 | h2 { margin: 0 0 8px; color: #00FF41; font-size: 16px; } |
| 6 | </style> |
| 7 | <div class="card"> |
| 8 | <h2>Card Title</h2> |
| 9 | <slot><!-- Fallback content here --></slot> |
| 10 | </div> |
| 11 | </template> |
| 12 | |
| 13 | <!-- Usage: children go into the default slot --> |
| 14 | <my-card> |
| 15 | <p>This content renders inside the slot.</p> |
| 16 | </my-card> |
Named Slots
Named slots allow multiple insertion points. Light DOM elements specify which slot they belong to using the slot attribute.
| 1 | <template id="my-dialog"> |
| 2 | <style> |
| 3 | .overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; } |
| 4 | .dialog { background: #111; border: 1px solid #333; border-radius: 8px; min-width: 320px; } |
| 5 | .header { padding: 16px; border-bottom: 1px solid #222; font-weight: 600; } |
| 6 | .body { padding: 16px; } |
| 7 | .footer { padding: 12px 16px; border-top: 1px solid #222; display: flex; gap: 8px; justify-content: flex-end; } |
| 8 | </style> |
| 9 | <div class="overlay"> |
| 10 | <div class="dialog"> |
| 11 | <div class="header"><slot name="header">Header</slot></div> |
| 12 | <div class="body"><slot><!-- Body content --></slot></div> |
| 13 | <div class="footer"><slot name="footer"></slot></div> |
| 14 | </div> |
| 15 | </div> |
| 16 | </template> |
| 17 | |
| 18 | <!-- Usage with named slots --> |
| 19 | <my-dialog> |
| 20 | <h2 slot="header">Confirm Deletion</h2> |
| 21 | <p>Are you sure you want to delete this item?</p> |
| 22 | <div slot="footer"> |
| 23 | <button>Cancel</button> |
| 24 | <button>Delete</button> |
| 25 | </div> |
| 26 | </my-dialog> |
Fallback Content
Slot elements can contain fallback content that renders when no light DOM children are assigned to that slot. This provides sensible defaults while allowing customization.
| 1 | <template id="my-button"> |
| 2 | <style> |
| 3 | button { padding: 10px 20px; border-radius: 4px; border: none; cursor: pointer; } |
| 4 | .primary { background: #00FF41; color: #000; } |
| 5 | .secondary { background: #333; color: #E0E0E0; } |
| 6 | </style> |
| 7 | <button class="primary"> |
| 8 | <slot><!-- Default: "Click Me" -->Click Me</slot> |
| 9 | </button> |
| 10 | </template> |
| 11 | |
| 12 | <!-- No children — shows fallback --> |
| 13 | <my-button></my-button> |
| 14 | |
| 15 | <!-- With children — replaces fallback --> |
| 16 | <my-button> |
| 17 | <strong>Save Changes</strong> |
| 18 | </my-button> |
best practice
Shadow DOM provides several CSS selectors and mechanisms for styling from both inside and outside the shadow boundary.
:host
The :host pseudo-class selects the host element from within the shadow tree. This is the primary way to style the component's outer container.
| 1 | /* Inside the shadow tree */ |
| 2 | |
| 3 | /* Style the host element */ |
| 4 | :host { |
| 5 | display: block; |
| 6 | font-family: system-ui; |
| 7 | border-radius: 8px; |
| 8 | } |
| 9 | |
| 10 | /* Style host when a class is applied */ |
| 11 | :host(.active) { |
| 12 | border-color: #00FF41; |
| 13 | } |
| 14 | |
| 15 | /* Style host based on attributes */ |
| 16 | :host([disabled]) { |
| 17 | opacity: 0.5; |
| 18 | pointer-events: none; |
| 19 | } |
| 20 | |
| 21 | /* Style host based on parent context */ |
| 22 | :host-context(.dark-theme) { |
| 23 | background: #111; |
| 24 | color: #E0E0E0; |
| 25 | } |
::slotted()
The ::slotted() pseudo-element selects light DOM children that are assigned to a slot. It can only style top-level assigned nodes, not their descendants.
| 1 | /* Inside the shadow tree */ |
| 2 | ::slotted(p) { |
| 3 | color: #E0E0E0; |
| 4 | line-height: 1.6; |
| 5 | } |
| 6 | |
| 7 | ::slotted([slot="header"]) { |
| 8 | font-size: 18px; |
| 9 | font-weight: 600; |
| 10 | color: #00FF41; |
| 11 | } |
| 12 | |
| 13 | /* Does NOT work — only top-level assigned nodes */ |
| 14 | ::slotted(p) span { |
| 15 | /* This will not apply */ |
| 16 | } |
CSS Custom Properties
CSS custom properties (variables) are the only CSS feature that can cross the shadow boundary. They provide a safe, intentional theming API for web components.
| 1 | <!-- Shadow tree uses CSS custom properties --> |
| 2 | <template id="my-badge"> |
| 3 | <style> |
| 4 | :host { |
| 5 | --badge-bg: var(--my-badge-bg, #00FF41); |
| 6 | --badge-color: var(--my-badge-color, #000); |
| 7 | --badge-radius: var(--my-badge-radius, 4px); |
| 8 | } |
| 9 | span { |
| 10 | background: var(--badge-bg); |
| 11 | color: var(--badge-color); |
| 12 | border-radius: var(--badge-radius); |
| 13 | padding: 4px 10px; |
| 14 | font-size: 12px; |
| 15 | font-weight: 600; |
| 16 | } |
| 17 | </style> |
| 18 | <span><slot></slot></span> |
| 19 | </template> |
| 20 | |
| 21 | <!-- Consumer customizes via CSS custom properties --> |
| 22 | <style> |
| 23 | my-badge { |
| 24 | --my-badge-bg: #333; |
| 25 | --my-badge-color: #00FF41; |
| 26 | --my-badge-radius: 20px; |
| 27 | } |
| 28 | </style> |
| 29 | <my-badge>New</my-badge> |
The CSS ::part() pseudo-element allows consumers to style specific internal elements of a shadow tree by exposing them via the part attribute. This provides a controlled API for customization without breaking encapsulation.
| 1 | <!-- Component exposes parts --> |
| 2 | <template id="my-slider"> |
| 3 | <style> |
| 4 | .track { height: 6px; background: #333; border-radius: 3px; } |
| 5 | .fill { height: 100%; background: #00FF41; border-radius: 3px; width: 50%; } |
| 6 | .thumb { width: 20px; height: 20px; background: #fff; border-radius: 50%; border: 2px solid #00FF41; } |
| 7 | </style> |
| 8 | <div class="track" part="track"> |
| 9 | <div class="fill" part="fill"> |
| 10 | <div class="thumb" part="thumb"></div> |
| 11 | </div> |
| 12 | </div> |
| 13 | </template> |
| 14 | |
| 15 | <!-- Consumer styles via ::part() --> |
| 16 | <style> |
| 17 | my-slider::part(track) { |
| 18 | background: #1A1A1A; |
| 19 | } |
| 20 | my-slider::part(fill) { |
| 21 | background: #0088FF; |
| 22 | } |
| 23 | my-slider::part(thumb) { |
| 24 | border-color: #0088FF; |
| 25 | box-shadow: 0 0 8px rgba(0, 136, 255, 0.5); |
| 26 | } |
| 27 | </style> |
| 28 | <my-slider></my-slider> |
The exportparts attribute re-exports parts from nested shadow trees, allowing composition to maintain customization reach:
| 1 | <!-- Outer component re-exports inner parts --> |
| 2 | <template id="my-composed-card"> |
| 3 | <my-slider exportparts="track, thumb: slider-thumb"> |
| 4 | </my-slider> |
| 5 | </template> |
| 6 | |
| 7 | <!-- Consumer can style either */ |
| 8 | my-composed-card::part(track) { } |
| 9 | my-composed-card::part(slider-thumb) { } |
Events fired inside a shadow tree are retargeted so that, from the outside, the event appears to originate from the host element. This preserves encapsulation — external code does not need to know about the internal DOM structure.
Most events are composed by default, meaning they cross the shadow boundary. Some events (like focus, blur, mouseenter, mouseleave) do not compose. You can create composed custom events using the composed: true option:
| 1 | // Inside a custom element's shadow tree |
| 2 | class MyWidget extends HTMLElement { |
| 3 | constructor() { |
| 4 | super(); |
| 5 | this.attachShadow({ mode: 'open' }); |
| 6 | this.shadowRoot.innerHTML = ` |
| 7 | <button id="action">Click</button> |
| 8 | `; |
| 9 | } |
| 10 | |
| 11 | connectedCallback() { |
| 12 | this.shadowRoot.getElementById('action') |
| 13 | .addEventListener('click', (e) => { |
| 14 | // Dispatch a composed event that crosses shadow boundary |
| 15 | this.dispatchEvent(new CustomEvent('widget-action', { |
| 16 | detail: { value: 42 }, |
| 17 | bubbles: true, |
| 18 | composed: true, // Allows crossing shadow boundary |
| 19 | })); |
| 20 | |
| 21 | // e.target is retargeted to the host element |
| 22 | console.log(e.target); // <button> inside shadow (inside) |
| 23 | }); |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // Outside listener sees the host as target |
| 28 | widget.addEventListener('widget-action', (e) => { |
| 29 | console.log(e.target); // <my-widget> (the host) |
| 30 | console.log(e.detail); // { value: 42 } |
| 31 | }); |
When to Use Shadow DOM
Accessibility Considerations
Live preview of a styled shadow DOM component with slot composition:
note
pro tip