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:
- Style Encapsulation: CSS rules defined inside a shadow tree stay inside. They won't affect the outer document, and outer CSS won't penetrate the shadow boundary by default.
- DOM Encapsulation: The internal DOM structure is hidden from
document.querySelectorand other DOM traversal methods. This prevents accidental manipulation of component internals. - Composition Control: Through
<slot>elements, Shadow DOM allows controlled, declarative composition of content provided by the light DOM (the regular document DOM). - True Reusability: You can ship a self-contained component that renders consistently regardless of the surrounding page's CSS or JavaScript.
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: The shadow root is accessible via the host's
shadowRootproperty. This allows external JavaScript to inspect and manipulate the shadow tree. Use this when you want to expose an API for programmatic access. - Closed mode: The host's
shadowRootproperty returnsnull. The shadow tree is completely inaccessible from outside. Use this for fully private implementation details, but be aware that it also prevents debugging tools from inspecting the component.
<!-- 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
<article><aside><blockquote><div><footer><header><main><nav><p><section><span>- Any custom element with a valid name
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
- CSS selectors inside shadow DOM cannot match elements in the light DOM.
- CSS selectors in the light DOM cannot match elements inside shadow DOM (with one exception: custom properties and explicitly inheritable styles).
- The shadow host itself is in the light DOM and can be styled from the outer document. Those styles can be used as a baseline that trickles into the shadow tree via inheritance.
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:
- Events that are
composed: trueby default include:click,input,change,keydown,keyup,focus,blur, and most UI events. - Events that are
composed: falseby default include:load,error, custom events, and most non-UI events.
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.