Understanding Shadow DOM in JavaScript: Encapsulation for Web Components
The Shadow DOM is one of the most powerful features in modern web development. It allows you to create a completely encapsulated DOM tree inside an element, with its own styles, markup, and behavior that remain isolated from the rest of the page. If you have ever wondered how native HTML elements like <video>, <input type="range">, or <select> manage their complex internal UI without leaking styles or structure into your page, the answer is Shadow DOM.
In this guide, you will learn what Shadow DOM is, how to create and interact with it, the difference between open and closed modes, how it relates to Light DOM, and how events behave when crossing shadow boundaries.
What Is Shadow DOM?
At its core, Shadow DOM is a mechanism that lets you attach a hidden, encapsulated DOM subtree to any element. This subtree is completely separated from the main document DOM, which means:
- Styles defined inside the shadow tree do not leak out to the rest of the page.
- Styles defined outside do not reach into the shadow tree (with very few exceptions).
- IDs and class names inside the shadow tree will never conflict with those in the main document.
- JavaScript queries like
document.querySelector()cannot see inside the shadow tree.
Think of it as creating a mini-document inside an element, with its own private scope.
How the Browser Already Uses Shadow DOM
You have been interacting with Shadow DOM without knowing it. Open your browser DevTools, enable "Show user agent shadow DOM" in the settings, and inspect a <video> element or an <input type="range">:
<input type="range" min="0" max="100">
You will see that the browser internally attaches a shadow root to these elements. Inside, there is a complete tree of <div> elements that handle the slider track, the thumb, and other visual parts. This is the user-agent Shadow DOM, and it is the same technology now available to you as a web developer.
The Big Picture
Document DOM (Light DOM)
│
├── <html>
│ ├── <head>...</head>
│ └── <body>
│ ├── <h1>My Page</h1>
│ └── <my-card>
│ │
│ └── #shadow-root (Shadow DOM)
│ ├── <style> ... scoped styles ... </style>
│ ├── <div class="card-header">...</div>
│ └── <div class="card-body">
│ └── <slot></slot> ← Light DOM content goes here
│ </div>
The #shadow-root is the boundary. Everything inside it is invisible to the outside document.
Creating a Shadow DOM with attachShadow()
To attach a shadow tree to an element, you use the attachShadow() method. This method accepts an options object with a required mode property.
Basic Syntax
const shadowRoot = element.attachShadow({ mode: 'open' });
This creates a shadow root attached to element and returns it. You can then populate it with content using standard DOM methods or innerHTML.
A Complete Example
<!DOCTYPE html>
<html>
<head>
<style>
/* This style is in the main document */
p { color: blue; font-size: 20px; }
</style>
</head>
<body>
<p>This paragraph is blue and 20px (main document style).</p>
<div id="host"></div>
<script>
const host = document.getElementById('host');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
`;
</script>
</body>
</html>
What you see on the page:
- The first paragraph is blue and 20px because the main document styles apply to it.
- The second paragraph (inside the shadow root) is red and 14px because only the shadow styles apply to it.
The main document p { color: blue; } rule has zero effect inside the shadow tree. That is encapsulation in action.
Which Elements Can Host a Shadow Root?
Not every element can have a shadow root attached. You can attach shadow DOM to:
- Custom elements (any element registered with
customElements.define()) - A specific list of built-in elements:
article,aside,blockquote,body,div,footer,h1-h6,header,main,nav,p,section,span
// This works
document.createElement('div').attachShadow({ mode: 'open' });
// This throws an error
document.createElement('img').attachShadow({ mode: 'open' });
// DOMException: Failed to execute 'attachShadow' on 'Element'
You can only attach one shadow root to any given element. Attempting to call attachShadow() on an element that already has a shadow root will throw an error.
const div = document.createElement('div');
div.attachShadow({ mode: 'open' });
div.attachShadow({ mode: 'open' }); // DOMException: Element already has a shadow root
Open vs. Closed Mode
The mode option is the most important decision you make when creating a shadow root. It determines whether the shadow tree is accessible from outside JavaScript.
Open Mode (mode: 'open')
With open mode, the shadow root is accessible via the element.shadowRoot property:
const host = document.getElementById('host');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Hello from the shadows!</p>';
// Later, from anywhere in your code:
console.log(host.shadowRoot);
// Output: #shadow-root (open)
console.log(host.shadowRoot.innerHTML);
// Output: <p>Hello from the shadows!</p>
console.log(host.shadowRoot.querySelector('p').textContent);
// Output: Hello from the shadows!
Open mode is the most common choice. It allows external code (including DevTools, testing frameworks, and other scripts) to inspect and interact with the shadow content.
Closed Mode (mode: 'closed')
With closed mode, element.shadowRoot returns null:
const host = document.getElementById('host');
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = '<p>Secret content!</p>';
// From outside:
console.log(host.shadowRoot);
// Output: null
The shadow root reference is only available to the code that created it (i.e., whoever stored the return value of attachShadow()).
class SecretBox extends HTMLElement {
#shadow; // Private field to store the reference
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'closed' });
this.#shadow.innerHTML = `
<style>
.secret { color: green; font-weight: bold; }
</style>
<p class="secret">This is private content.</p>
`;
}
// Internal methods can still access #shadow
updateContent(text) {
this.#shadow.querySelector('.secret').textContent = text;
}
}
customElements.define('secret-box', SecretBox);
<secret-box></secret-box>
<script>
const box = document.querySelector('secret-box');
console.log(box.shadowRoot); // null - cannot access from outside
box.updateContent('Updated via public API');
</script>
Open vs. Closed: Which One Should You Use?
| Aspect | mode: 'open' | mode: 'closed' |
|---|---|---|
element.shadowRoot | Returns the shadow root | Returns null |
| External access | Possible | Not directly possible |
| DevTools inspection | Easy | Still visible in DevTools |
| Testing | Straightforward | Harder to test |
| True security? | No | No (not a security boundary) |
| Common usage | Most components | Rare, native elements |
Closed mode is not a security feature. A determined developer can still access a closed shadow root through various techniques (e.g., overriding attachShadow before the component loads). Closed mode is about signaling intent: "This internal structure is an implementation detail, do not rely on it." For almost all use cases, open mode is the recommended choice.
Shadow Root and Its Scope
The shadow root acts as the document root for everything inside the shadow tree. It has its own scope for styles, selectors, and element queries.
The Shadow Root Object
The shadow root is a DocumentFragment-like node with some special properties:
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
console.log(shadow.constructor.name);
// Output: ShadowRoot
console.log(shadow.host);
// Output: <div> (the element the shadow is attached to)
console.log(shadow.mode);
// Output: "open"
console.log(shadow instanceof DocumentFragment);
// Output: true
Key properties of a shadow root:
shadow.host: Reference back to the element that owns this shadow root.shadow.mode: Either"open"or"closed".shadow.innerHTML: You can get/set the entire shadow tree HTML.shadow.querySelector()/shadow.querySelectorAll(): Search only within the shadow tree.
Style Scoping
This is one of the most valuable features of Shadow DOM. Styles are completely scoped to the shadow tree.
class StyledWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* This .title class ONLY affects elements inside this shadow tree */
.title {
color: crimson;
font-size: 24px;
border-bottom: 2px solid crimson;
}
.content {
padding: 10px;
background: #f9f9f9;
}
</style>
<div class="title">Widget Title</div>
<div class="content">Widget content here.</div>
`;
}
}
customElements.define('styled-widget', StyledWidget);
<style>
/* Main document styles - these will NOT affect the widget */
.title { color: green; font-size: 40px; }
.content { background: yellow; }
</style>
<div class="title">Page Title (green, 40px)</div>
<styled-widget></styled-widget>
<div class="content">Page content (yellow background)</div>
Result:
- "Page Title" is green and 40px (main document style).
- "Widget Title" is crimson and 24px (shadow style).
- "Page content" has a yellow background (main document style).
- "Widget content here." has a #f9f9f9 background (shadow style).
No conflicts, no specificity battles, no CSS naming methodology needed inside the component.
Selector Scoping
document.querySelector() cannot reach inside shadow trees:
const widget = document.querySelector('styled-widget');
// Searching from the document level:
console.log(document.querySelector('.title'));
// Output: <div class="title">Page Title</div>
// (Only finds the main document element, not the one inside shadow DOM)
console.log(document.querySelectorAll('.title').length);
// Output: 1 (not 2!)
// To find elements inside the shadow:
console.log(widget.shadowRoot.querySelector('.title'));
// Output: <div class="title">Widget Title</div>
ID Scoping
IDs inside the shadow tree are isolated from the main document:
<div id="app">Main app</div>
<script>
class MyPanel extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = '<div id="app">Shadow app</div>';
}
}
customElements.define('my-panel', MyPanel);
</script>
<my-panel></my-panel>
<script>
// Only finds the main document #app
console.log(document.getElementById('app').textContent);
// Output: Main app
const panel = document.querySelector('my-panel');
console.log(panel.shadowRoot.getElementById('app').textContent);
// Output: Shadow app
</script>
Two elements with id="app" coexist without any conflict because they are in different DOM scopes.
Some CSS properties are inherited through the shadow boundary. Properties like color, font-family, font-size, line-height, and other inherited properties pass down from the host element into the shadow tree. This is intentional so that components blend with the page's typography. You can override this behavior inside the shadow styles if needed.
shadow.innerHTML = `
<style>
:host {
/* Reset all inherited styles */
all: initial;
}
</style>
<p>Completely isolated from inherited styles.</p>
`;
Light DOM vs. Shadow DOM
When working with web components, two terms come up constantly: Light DOM and Shadow DOM. Understanding the distinction is essential.
Definitions
-
Light DOM: The regular children of your custom element, written directly in the HTML by the component consumer. This is the "normal" DOM that anyone can see and access.
-
Shadow DOM: The internal, hidden DOM tree attached via
attachShadow(). This is the component's implementation detail.
<!-- Light DOM: what the user writes -->
<user-card>
<span slot="name">Alice Johnson</span>
<span slot="email">alice@example.com</span>
</user-card>
// Shadow DOM: what the component author creates
class UserCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.card { border: 1px solid #ddd; padding: 16px; border-radius: 8px; }
.name { font-weight: bold; font-size: 18px; }
.email { color: #666; }
</style>
<div class="card">
<div class="name"><slot name="name">Default Name</slot></div>
<div class="email"><slot name="email">no-email@example.com</slot></div>
</div>
`;
}
}
customElements.define('user-card', UserCard);
How They Work Together
The <slot> elements in the Shadow DOM act as insertion points for the Light DOM content. The component author defines where Light DOM content appears, while the component user decides what content to provide.
Final Rendered Result (Flattened Tree):
┌────────────────────────────────────┐
│ <user-card> │
│ #shadow-root │
│ ├── <style>...</style> │
│ └── <div class="card"> │
│ ├── <div class="name"> │
│ │ └── "Alice Johnson" ← from Light DOM
│ └── <div class="email"> │
│ └── "alice@example.com" ← from Light DOM
│ └── </div> │
│ </div> │
│ │
│ (Light DOM children:) │
│ ├── <span slot="name"> │
│ └── <span slot="email"> │
└────────────────────────────────────┘
Where Do Light DOM Children Live?
An important subtlety: Light DOM children are not moved into the shadow tree. They remain as children of the host element in the actual DOM. The <slot> mechanism creates a visual projection of those children into the shadow tree.
const card = document.querySelector('user-card');
// Light DOM children are still accessible normally
console.log(card.children.length);
// Output: 2 (the two <span> elements)
console.log(card.querySelector('[slot="name"]').textContent);
// Output: Alice Johnson
// Shadow DOM is separate
console.log(card.shadowRoot.querySelector('.card'));
// Output: <div class="card">...</div>
Practical Comparison
Here is a more hands-on example showing the separation:
<my-widget>
<p>I am Light DOM content</p>
</my-widget>
<script>
class MyWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
`;
}
}
customElements.define('my-widget', MyWidget);
const widget = document.querySelector('my-widget');
// Querying from document - finds Light DOM
console.log(document.querySelector('my-widget p').textContent);
// Output: I am Light DOM content
// Querying from shadow root - finds Shadow DOM
console.log(widget.shadowRoot.querySelector('h3').textContent);
// Output: Shadow DOM heading
// Shadow root cannot see Light DOM children
console.log(widget.shadowRoot.querySelector('p'));
// Output: null (!)
// Document cannot see Shadow DOM elements
console.log(document.querySelector('.wrapper'));
// Output: null (!)
</script>
When There Is No Shadow DOM
If a custom element does not attach a shadow root, it works purely with Light DOM:
class SimpleGreeting extends HTMLElement {
connectedCallback() {
// No shadow DOM - directly modifying Light DOM
this.innerHTML = `<p>Hello, ${this.getAttribute('name')}!</p>`;
}
}
customElements.define('simple-greeting', SimpleGreeting);
<simple-greeting name="World"></simple-greeting>
This works, but without encapsulation. Any page style targeting p will affect this component's output.
Shadow DOM and the Composed Flag for Events
Events in Shadow DOM have special behavior. By default, when an event is triggered on an element inside a shadow tree, it does not cross the shadow boundary. But some events do. Understanding this distinction is critical for building interactive components.
Event Retargeting
When an event that occurs inside a shadow tree bubbles up and crosses the shadow boundary, the browser retargets the event. From the perspective of the outer document, the event appears to come from the host element, not from the internal shadow element.
class ClickableBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
<button id="inner-btn">Click Me</button>
`;
}
}
customElements.define('clickable-box', ClickableBox);
<clickable-box id="mybox"></clickable-box>
<script>
const box = document.getElementById('mybox');
// Listening on the host element (outside shadow DOM)
box.addEventListener('click', (e) => {
console.log('event.target:', e.target);
// Output: event.target: <clickable-box id="mybox">
// NOT: <button id="inner-btn">
console.log('event.target.tagName:', e.target.tagName);
// Output: CLICKABLE-BOX
});
// Listening inside the shadow DOM
box.shadowRoot.querySelector('button').addEventListener('click', (e) => {
console.log('Inside shadow - event.target:', e.target);
// Output: Inside shadow - event.target: <button id="inner-btn">
});
</script>
The button click inside the shadow tree is retargeted to the host element when observed from outside the shadow boundary. This preserves encapsulation since outside code should not need to know about the internal structure.
The composed Property
Every event has a composed property (boolean) that determines whether the event can cross shadow DOM boundaries.
composed: true: The event will cross shadow boundaries, bubbling from the shadow tree to the document.composed: false: The event stays inside the shadow tree. It does not bubble past the shadow root.
Most native UI events are composed:
// Inside a shadow root:
shadow.querySelector('button').addEventListener('click', (e) => {
console.log(e.composed); // true - click crosses shadow boundary
});
shadow.querySelector('input').addEventListener('focus', (e) => {
console.log(e.composed); // true - focus crosses shadow boundary
});
Events that are composed: true (cross shadow boundaries):
| Event | Composed |
|---|---|
click, dblclick, mousedown, mouseup | true |
touchstart, touchend, touchmove | true |
pointerdown, pointerup, pointermove | true |
keydown, keyup, keypress | true |
focus, blur, focusin, focusout | true |
input, change | true |
compositionstart, compositionend | true |
wheel, scroll | true |
Events that are composed: false (stay inside shadow):
| Event | Composed |
|---|---|
mouseenter, mouseleave | false |
load, unload, abort, error | false |
select | false |
slotchange | false |
Custom Events and the composed Flag
When you dispatch custom events, they are not composed by default. You must explicitly set composed: true if you want them to cross the shadow boundary.
class NotificationBanner extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.banner { padding: 12px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; }
button { margin-left: 10px; }
</style>
<div class="banner">
<span>You have a new message!</span>
<button id="dismiss">Dismiss</button>
</div>
`;
shadow.querySelector('#dismiss').addEventListener('click', () => {
// This event WILL cross the shadow boundary
this.dispatchEvent(new CustomEvent('banner-dismiss', {
bubbles: true,
composed: true, // Key: allows crossing shadow boundary
detail: { reason: 'user-clicked' }
}));
// This event will NOT cross the shadow boundary
shadow.dispatchEvent(new CustomEvent('internal-log', {
bubbles: true,
composed: false // Default - stays inside shadow
}));
});
}
}
customElements.define('notification-banner', NotificationBanner);
<notification-banner></notification-banner>
<script>
const banner = document.querySelector('notification-banner');
// This works - composed: true allows it to reach the document
banner.addEventListener('banner-dismiss', (e) => {
console.log('Banner dismissed:', e.detail.reason);
// Output: Banner dismissed: user-clicked
banner.remove();
});
// This NEVER fires - composed: false keeps the event inside shadow
banner.addEventListener('internal-log', () => {
console.log('This will never appear');
});
</script>
When dispatching custom events from a web component, a best practice is to dispatch from the host element (this) rather than from an internal shadow element. This way, even without composed: true, the event is already in the Light DOM and can be listened to normally on the host.
// Recommended pattern:
this.dispatchEvent(new CustomEvent('my-event', {
bubbles: true,
composed: true,
detail: { /* data */ }
}));
event.composedPath()
The composedPath() method returns the full path of the event, including elements inside shadow trees. This gives you the complete picture of where the event traveled, even across shadow boundaries.
class DeepComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div class="inner">
<button>Deep Button</button>
</div>
`;
}
}
customElements.define('deep-component', DeepComponent);
<div id="outer">
<deep-component></deep-component>
</div>
<script>
document.getElementById('outer').addEventListener('click', (e) => {
console.log('event.target:', e.target);
// Output: <deep-component> (retargeted)
console.log('event.composedPath():');
e.composedPath().forEach(el => {
console.log(' ', el.tagName || el.constructor.name);
});
// Output:
// BUTTON ← actual origin (inside shadow)
// DIV ← .inner (inside shadow)
// #document-fragment ← shadow root
// DEEP-COMPONENT ← host element
// DIV ← #outer
// BODY
// HTML
// HTMLDocument
// Window
});
</script>
With composedPath(), you can see the full event path including shadow internals. This is particularly useful for debugging and for cases where you need to understand exactly which element was originally clicked.
For closed shadow roots, composedPath() does not include the elements inside the shadow tree when called from outside the shadow. The path starts from the host element. This is another layer of encapsulation that closed mode provides.
// With mode: 'closed', from outside the shadow:
document.addEventListener('click', (e) => {
console.log(e.composedPath());
// The shadow internals are NOT included in the path
// Path starts from the host element
});
Putting It All Together: A Complete Example
Here is a practical example that demonstrates all the concepts covered in this guide:
class SearchBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: inline-block;
font-family: inherit;
}
.search-container {
display: flex;
border: 2px solid #ccc;
border-radius: 24px;
overflow: hidden;
transition: border-color 0.2s;
}
.search-container:focus-within {
border-color: #4285f4;
}
input {
border: none;
padding: 8px 16px;
font-size: 16px;
outline: none;
flex: 1;
min-width: 200px;
}
button {
background: #4285f4;
color: white;
border: none;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #3367d6;
}
</style>
<div class="search-container">
<input type="text" placeholder="Search..." />
<button>Search</button>
</div>
`;
const input = shadow.querySelector('input');
const button = shadow.querySelector('button');
button.addEventListener('click', () => this.#emitSearch(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.#emitSearch(input.value);
});
}
#emitSearch(query) {
this.dispatchEvent(new CustomEvent('search', {
bubbles: true,
composed: true,
detail: { query: query.trim() }
}));
}
// Public API to get/set value
get value() {
return this.shadowRoot.querySelector('input').value;
}
set value(val) {
this.shadowRoot.querySelector('input').value = val;
}
}
customElements.define('search-box', SearchBox);
<!-- Usage - Light DOM is clean and simple -->
<h1>My Application</h1>
<search-box id="search"></search-box>
<div id="results"></div>
<style>
/* These styles have ZERO effect on the search box internals */
input { border: 5px solid red; }
button { background: orange; }
</style>
<script>
document.getElementById('search').addEventListener('search', (e) => {
document.getElementById('results').textContent =
`You searched for: "${e.detail.query}"`;
});
</script>
In this example:
- The
<search-box>component has fully encapsulated styles. - The page-level
inputandbuttonstyles do not affect it. - Events cross the shadow boundary via
composed: true. - A clean public API (
valuegetter/setter) provides interaction without exposing internals.
Summary
| Concept | Key Point |
|---|---|
| Shadow DOM | An encapsulated DOM subtree with its own styles and scope |
attachShadow() | Creates a shadow root on an element |
mode: 'open' | Shadow root accessible via element.shadowRoot |
mode: 'closed' | element.shadowRoot returns null |
| Style scoping | Styles inside shadow do not leak out, outside styles do not leak in |
| Selector scoping | document.querySelector() cannot find shadow elements |
| Light DOM | Regular children written by the component user |
| Shadow DOM | Internal structure created by the component author |
| Event retargeting | Events appear to come from the host element outside the shadow |
composed: true | Event crosses shadow boundaries |
composed: false | Event stays inside the shadow tree |
composedPath() | Returns the full event path including shadow internals |
Shadow DOM is the foundation of truly encapsulated, reusable web components. Combined with Custom Elements, templates, and slots, it gives you the tools to build components that work reliably in any context without worrying about CSS conflicts or DOM interference.