|$ curl https://forge-ai.dev/api/markdown?path=docs/html/shadow-dom
$cat docs/shadow-dom.md
updated Last week·45 min read·published

Shadow DOM

HTMLWeb ComponentsReferenceIntermediate
Introduction

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

Shadow DOM is supported in all modern browsers. It is one of three Web Components standards alongside Custom Elements and HTML Templates. You can use shadow DOM independently or combine all three for full encapsulation.
Shadow Tree

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:

ModeDescriptionAccess
openShadow root is accessible via element.shadowRootelement.shadowRoot returns the root
closedShadow root is inaccessible from outsideelement.shadowRoot returns null
shadow-tree.js
JavaScript
1// Open mode — default for most use cases
2const host = document.getElementById('host');
3const shadowRoot = host.attachShadow({ mode: 'open' });
4console.log(host.shadowRoot); // ShadowRoot object
5
6// Closed mode — stricter isolation
7const closedHost = document.getElementById('closed');
8const closedRoot = closedHost.attachShadow({ mode: 'closed' });
9console.log(closedHost.shadowRoot); // null
10
11// Populate the shadow tree
12shadowRoot.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
20const div = document.createElement('div');
21div.textContent = 'Also inside shadow DOM';
22shadowRoot.appendChild(div);

warning

Closed shadow DOM mode provides nominal isolation but can be bypassed (e.g., via Event.composedPath()). It also prevents framework access (React/Vue refs cannot see closed roots). Prefer open mode unless you have a specific security reason for closed.
Encapsulation

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.

style-scoping.html
HTML
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.

dom-isolation.js
JavaScript
1// DOM isolation in action
2const host = document.createElement('div');
3const root = host.attachShadow({ mode: 'open' });
4root.innerHTML = `
5 <div id="internal">
6 <p>Hidden from outside queries</p>
7 </div>
8`;
9
10document.body.appendChild(host);
11
12// These return null — the shadow tree is isolated
13console.log(document.querySelector('#internal')); // null
14console.log(document.querySelector('p')); // null (if only inside shadow)
15
16// Access via the shadow root
17console.log(host.shadowRoot.querySelector('#internal')); // found!
Composition

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.

default-slot.html
HTML
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.

named-slots.html
HTML
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.

fallback-slot.html
HTML
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

Always provide fallback content for slots when the component has a useful default. This makes the component easier to use while still allowing full customization. Fallback content renders inside the shadow tree and is not projected into the light DOM.
Styling Shadow DOM

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.

host.css
CSS
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.

slotted.css
CSS
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.

css-custom-props.html
HTML
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>
Part-based Styling

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.

part-based.html
HTML
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:

export-parts.html
HTML
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 */
8my-composed-card::part(track) { }
9my-composed-card::part(slider-thumb) { }
Event Retargeting

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:

event-retargeting.js
JavaScript
1// Inside a custom element's shadow tree
2class 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
28widget.addEventListener('widget-action', (e) => {
29 console.log(e.target); // <my-widget> (the host)
30 console.log(e.detail); // { value: 42 }
31});
Best Practices

When to Use Shadow DOM

Use shadow DOM when you need complete style isolation from the host page
Use shadow DOM for reusable component libraries and design systems
Use shadow DOM for third-party widgets embedded in unknown environments
Consider light DOM for simple presentational components that inherit global styles
Avoid shadow DOM when you want component internals to be styled by global CSS
Use open mode unless you have specific security requirements

Accessibility Considerations

ARIA attributes on the host element are inherited into the shadow tree
Use aria-labelledby to connect slotted content with shadow DOM elements
Ensure focus management works across shadow boundaries (delegatesFocus option)
Screen readers traverse shadow trees automatically in modern browsers
Test with actual screen readers — shadow DOM accessibility is well-supported but nuanced
Use the focusVisible polyfill if supporting older browsers

Live preview of a styled shadow DOM component with slot composition:

preview
📝

note

Styling via ::part() is the recommended approach for exposing customization points. CSS custom properties are best for theming variables (colors, spacing, fonts). Avoid relying on :host-context() as it is not supported in all browsers.
🔥

pro tip

Shadow DOM is incredibly powerful for component isolation, but it adds complexity. For simple presentational components, consider light DOM with a naming convention like BEM. Reserve shadow DOM for components that truly need style isolation or are distributed as third-party widgets.
$Blueprint — Engineering Documentation·Section ID: HTML-26·Revision: 1.0