What Is Shadow DOM?
Shadow DOM is a core web standard that enables encapsulated DOM trees and scoped styling inside web components. When you attach a shadow root to a host element, the browser creates a separate document fragment—a "shadow tree"—that is completely isolated from the main document's DOM and CSS. This means selectors, styles, and scripts from the outer page cannot accidentally leak into your component, and vice versa.
Think of it as a private sandbox for your component's internal structure. The browser's rendering engine composites the shadow tree together with the light DOM (the regular page content) seamlessly, so the user sees a unified UI. The <video> and <audio> elements are classic examples—their internal controls and UI live inside a shadow root that you never see in the page source.
Key Terminology
- Shadow host: The regular DOM element to which you attach a shadow root.
- Shadow root: The root node of the shadow tree, created via
element.attachShadow(). - Shadow tree: The entire DOM subtree living inside the shadow root.
- Light DOM: The regular, visible DOM of the host element's children (before they are projected into slots).
- Slot: A placeholder inside the shadow tree that projects light DOM content into specific positions.
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, building reusable UI components was fraught with risks. Global CSS would cascade into your widget and break its layout. Your component's internal markup could be accidentally queried or manipulated by external JavaScript. IDs and class names had to be carefully namespaced to avoid collisions. Shadow DOM solves these problems natively in the browser:
- Style encapsulation: CSS rules defined inside a shadow root do not leak out, and outside CSS does not bleed in (with a few intentional exceptions like inheritable properties and CSS custom properties).
- DOM encapsulation:
document.querySelectorcannot pierce into a shadow tree. Internal elements are hidden from external selectors and traversal. - Composition via slots: The
<slot>mechanism lets you compose components elegantly, projecting user-supplied content into predefined placeholders without losing encapsulation. - True reusability: You can ship a self-contained component that works reliably inside any framework or vanilla HTML page without fear of style or script conflicts.
How to Use Shadow DOM: A Step-by-Step Guide
Let's walk through the practical implementation, starting from the fundamentals and building up to a complete, production-ready component.
1. Attaching a Shadow Root
Every shadow DOM journey begins with attachShadow(). You call it on a regular DOM element (the host), passing an options object with a mode property. The mode determines whether the shadow root is accessible from outside JavaScript.
<!-- HTML -->
<div id="my-host"></div>
<script>
const host = document.getElementById('my-host');
// Open mode: the shadow root is accessible via host.shadowRoot
const openRoot = host.attachShadow({ mode: 'open' });
// Closed mode: host.shadowRoot returns null
// const closedRoot = host.attachShadow({ mode: 'closed' });
openRoot.innerHTML = `
<style>
p { color: darkblue; font-weight: bold; }
</style>
<p>I live in the shadow and am styled in isolation.</p>
`;
</script>
2. Understanding Open vs. Closed Mode
The choice between 'open' and 'closed' has significant implications:
- Open mode: The shadow root reference is exposed via
host.shadowRoot. This allows external code to introspect and manipulate the internal structure. Most component libraries and dev tools rely on open mode. Use it unless you have a compelling reason not to. - Closed mode: The shadow root is inaccessible from outside the component's own code. The host element's
shadowRootproperty returnsnull. This provides stronger isolation but makes debugging harder and prevents framework integrations. Closed mode is rarely necessary—the browser already prevents accidental CSS and selector leakage regardless of mode.
<script>
const host = document.querySelector('#my-host');
if (host.shadowRoot) {
// Only works in open mode
console.log('Shadow root is accessible');
} else {
console.log('Shadow root is closed or absent');
}
</script>
3. Building Your First Encapsulated Component
Let's create a custom <fancy-button> component that demonstrates complete style and DOM isolation. We'll use a custom element class with an open shadow root.
<!-- Usage in HTML -->
<fancy-button label="Click Me"></fancy-button>
<script>
class FancyButton extends HTMLElement {
constructor() {
super();
// Attach open shadow root
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const label = this.getAttribute('label') || 'Button';
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 28px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.btn:active {
transform: translateY(0);
}
</style>
<button class="btn">${label}</button>
`;
// Add event listener internally
this.shadowRoot.querySelector('.btn')
.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('fancy-click', {
bubbles: true,
composed: true,
detail: { timestamp: Date.now() }
}));
});
}
}
customElements.define('fancy-button', FancyButton);
</script>
Notice that the .btn styles inside the shadow tree have zero impact on any other .btn elements in the page. The gradient background and hover effects are completely sandboxed.
4. Composition with Slots
Slots are the mechanism that makes shadow DOM components composable. They act as insertion points where the host's light DOM children get projected into the shadow tree. There are two types: named slots and the default slot.
<!-- Using a card component with slots -->
<user-card>
<span slot="avatar">👤</span>
<h2 slot="name">Jane Doe</h2>
<p>This content goes into the default slot automatically.</p>
</user-card>
<script>
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
max-width: 350px;
font-family: system-ui, sans-serif;
background: #fafafa;
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
::slotted([slot="avatar"]) {
font-size: 40px;
display: inline-block;
}
::slotted([slot="name"]) {
margin: 0;
font-size: 1.2em;
color: #333;
}
.card-body {
color: #555;
line-height: 1.6;
}
</style>
<div class="card-header">
<slot name="avatar">Default Avatar</slot>
<slot name="name">Unknown User</slot>
</div>
<div class="card-body">
<slot>No description provided.</slot>
</div>
`;
}
}
customElements.define('user-card', UserCard);
</script>
The ::slotted() pseudo-element is crucial—it's the only way to style light DOM content that has been projected into slots. Normal descendant selectors inside the shadow tree cannot target slotted nodes because those nodes technically remain in the light DOM. The ::slotted() selector has limited capabilities (it can only style top-level slotted children, not nested descendants), which is intentional to preserve encapsulation boundaries.
5. Theming with CSS Custom Properties
While shadow DOM blocks regular CSS, CSS custom properties (variables) pierce through shadow boundaries by design. This gives consumers of your component a clean API for styling without breaking encapsulation.
<!-- Component consumer defines theme variables -->
<style>
:root {
--fancy-btn-bg: #ff6b6b;
--fancy-btn-color: white;
--fancy-btn-radius: 24px;
--fancy-btn-padding: 14px 32px;
}
</style>
<fancy-button label="Themed Button"></fancy-button>
<script>
class FancyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const label = this.getAttribute('label') || 'Button';
this.shadowRoot.innerHTML = `
<style>
.btn {
background: var(--fancy-btn-bg, #667eea);
color: var(--fancy-btn-color, white);
border-radius: var(--fancy-btn-radius, 8px);
padding: var(--fancy-btn-padding, 12px 28px);
border: none;
font-size: 16px;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.85;
}
</style>
<button class="btn">${label}</button>
`;
}
}
customElements.define('fancy-button', FancyButton);
</script>
This pattern is the foundation of design-system-friendly web components. By exposing CSS custom properties as a styling API, you allow complete visual customization without sacrificing encapsulation. Always provide sensible defaults using the var(--prop, fallback) syntax.
6. Event Handling Across Shadow Boundaries
Events fired inside a shadow tree do not automatically bubble out to the light DOM in a way external listeners can catch. You need to set composed: true on custom events, or rely on the fact that most built-in UI events (like click) already compose across shadow boundaries.
<script>
class NotificationToast extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
background: #323232;
color: #fff;
padding: 16px 24px;
border-radius: 8px;
font-family: system-ui;
display: flex;
align-items: center;
justify-content: space-between;
}
.dismiss-btn {
background: transparent;
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
}
</style>
<div class="message">
<slot>Notification</slot>
</div>
<button class="dismiss-btn">Dismiss</button>
`;
// Internal event listener
this.shadowRoot.querySelector('.dismiss-btn')
.addEventListener('click', () => {
// Dispatch custom event that crosses shadow boundary
this.dispatchEvent(new CustomEvent('toast-dismissed', {
bubbles: true, // Travel up the DOM tree
composed: true, // Cross shadow boundaries
detail: { toastId: this.id }
}));
});
}
}
customElements.define('notification-toast', NotificationToast);
// External listener works because composed: true
document.addEventListener('toast-dismissed', (e) => {
console.log('Toast dismissed:', e.detail.toastId);
e.target.remove();
});
</script>
Key rule: always set both bubbles: true and composed: true on custom events that need to be caught outside the shadow tree. Without composed: true, the event will be trapped inside the shadow root.
7. Advanced: Declarative Shadow DOM
Traditionally, shadow roots are created imperatively in JavaScript. This means server-rendered HTML cannot express shadow DOM directly—the component's JS must execute first. Declarative Shadow DOM solves this by allowing shadow trees to be defined in static HTML markup using a <template> with a shadowrootmode attribute.
<!-- Server-rendered HTML with declarative shadow DOM -->
<server-card>
<template shadowrootmode="open">
<style>
:host { display: block; border: 2px solid #ccc; padding: 16px; }
h3 { margin-top: 0; color: #333; }
</style>
<h3>Server-Rendered Card</h3>
<slot>Fallback content</slot>
</template>
<p>This paragraph is projected into the default slot.</p>
</server-card>
<script>
// The shadow root already exists from the declarative template.
// No attachShadow() call needed—just upgrade behavior if desired.
class ServerCard extends HTMLElement {
connectedCallback() {
// Shadow root is already present and populated
console.log('Declarative shadow root detected:', this.shadowRoot);
}
}
customElements.define('server-card', ServerCard);
</script>
Declarative shadow DOM is a game-changer for server-side rendering (SSR) and progressive enhancement. The component renders immediately with its encapsulated styles before any JavaScript executes, eliminating layout shift and flash-of-unstyled-content issues.
8. Real-World Example: A Tab Container Component
Let's build a complete, non-trivial component that combines slots, events, styling, and programmatic control. This tab container demonstrates how shadow DOM components can expose a rich imperative API while maintaining full encapsulation.
<!-- Usage -->
<tab-container>
<div slot="tab-labels">
<button data-tab="home">Home</button>
<button data-tab="profile">Profile</button>
<button data-tab="settings">Settings</button>
</div>
<div slot="tab-content" data-tab="home">
<h2>Welcome Home</h2>
<p>This is the home tab content.</p>
</div>
<div slot="tab-content" data-tab="profile">
<h2>Your Profile</h2>
<p>Profile details go here.</p>
</div>
<div slot="tab-content" data-tab="settings">
<h2>Settings</h2>
<p>Adjust your preferences.</p>
</div>
</tab-container>
<script>
class TabContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._activeTab = 'home';
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.tab-labels {
display: flex;
background: #f5f5f5;
border-bottom: 2px solid #ddd;
}
::slotted([slot="tab-labels"]) {
display: flex;
}
::slotted([slot="tab-labels"] button) {
background: none;
border: none;
padding: 14px 24px;
cursor: pointer;
font-size: 15px;
color: #666;
border-bottom: 3px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
::slotted([slot="tab-labels"] button.active) {
color: #764ba2;
border-bottom-color: #764ba2;
font-weight: 600;
}
.tab-content-area {
padding: 24px;
min-height: 150px;
}
::slotted([slot="tab-content"]) {
display: none;
}
::slotted([slot="tab-content"].active) {
display: block;
}
</style>
<div class="tab-labels">
<slot name="tab-labels"></slot>
</div>
<div class="tab-content-area">
<slot name="tab-content"></slot>
</div>
`;
this._setupTabSwitching();
this._activateTab(this._activeTab);
}
_setupTabSwitching() {
const labelSlot = this.shadowRoot.querySelector('slot[name="tab-labels"]');
labelSlot.addEventListener('click', (e) => {
const button = e.target.closest('button[data-tab]');
if (!button) return;
const tabName = button.getAttribute('data-tab');
this.selectTab(tabName);
});
// Observe slotted label changes
labelSlot.addEventListener('slotchange', () => {
this._activateTab(this._activeTab);
});
}
_activateTab(tabName) {
this._activeTab = tabName;
// Update label active states
const labels = this.shadowRoot
.querySelector('slot[name="tab-labels"]')
.assignedElements();
labels.forEach(el => {
const buttons = el.querySelectorAll('button[data-tab]');
buttons.forEach(btn => {
btn.classList.toggle(
'active',
btn.getAttribute('data-tab') === tabName
);
});
});
// Update content visibility
const contents = this.shadowRoot
.querySelector('slot[name="tab-content"]')
.assignedElements();
contents.forEach(el => {
el.classList.toggle(
'active',
el.getAttribute('data-tab') === tabName
);
});
// Notify external listeners
this.dispatchEvent(new CustomEvent('tab-change', {
bubbles: true,
composed: true,
detail: { activeTab: tabName }
}));
}
// Public API
selectTab(tabName) {
this._activateTab(tabName);
}
get activeTab() {
return this._activeTab;
}
}
customElements.define('tab-container', TabContainer);
// External listener
document.querySelector('tab-container')
.addEventListener('tab-change', (e) => {
console.log('Tab changed to:', e.detail.activeTab);
});
</script>
This example showcases several advanced patterns: using slotchange events to react to dynamic slot content, assignedElements() to programmatically access slotted nodes, a clean public API (selectTab(), activeTab), and ::slotted() styling with class-based state management on light DOM elements.
Best Practices
- Prefer open mode: Use
{ mode: 'open' }unless you have a concrete security requirement for closed mode. Open mode enables debugging via dev tools, testing, and framework integrations. - Use CSS custom properties for theming: Expose a styling API through
var()with sensible defaults. Never expect consumers to pierce shadow boundaries with piercing selectors like::partunless absolutely necessary. - Keep shadow trees lean: Put only structural and presentational markup in the shadow tree. Defer content-heavy rendering to slots so consumers control the actual content.
- Always set
composed: trueon custom events: If an event should be catchable outside the component, set bothbubbles: trueandcomposed: true. - Use
::slotted()sparingly: Its styling capabilities are intentionally limited. For complex slotted content styling, consider using CSS custom properties or exposing::partattributes on internal elements. - Handle
slotchangeevents: When your component relies on slotted content being present, listen forslotchangeto react to dynamic additions or removals. - Leverage declarative shadow DOM for SSR: Use
<template shadowrootmode>in server-rendered HTML to avoid layout shifts and ensure immediate style encapsulation. - Provide fallback slot content: Always include default content inside
<slot>tags so your component looks reasonable even when consumers omit certain slotted elements. - Keep component logic self-contained: Avoid reaching into the light DOM from the shadow tree (except through slots). Respect the encapsulation boundary in both directions.
Conclusion
Shadow DOM is no longer an experimental feature—it's a foundational web standard that powers the modern component model on the web platform. By mastering shadow roots, slots, ::slotted(), CSS custom properties, and event composition, you unlock the ability to build truly reusable, framework-agnostic UI components that work reliably in any HTML context. Whether you're building a design system, a widget library, or a full application with vanilla custom elements, Shadow DOM gives you the encapsulation guarantees that were once only achievable through heavy framework abstractions. Start with open mode, embrace slots for composition, use custom properties for theming, and always set composed: true on your custom events. With these patterns in your toolkit, you're ready to build robust, interoperable web components that stand the test of time.