The real problem Shadow DOM solves
Building for the web at scale has always run into a fundamental obstacle: everything is global. A new id or class can silently collide with something already on the page. CSS specificity escalates, style rules sprawl, and debugging turns into a hunt. Shadow DOM removes that friction by making both CSS and DOM local to a component. Instead of layering naming conventions or third-party tooling over the platform, you can bundle markup and styles together in a self-contained unit.
Shadow DOM is one of the three Web Component standards, alongside HTML Templates and Custom Elements. (HTML Imports were once part of that list but are now deprecated.) You don't have to use it to author custom elements, but when you do, you get CSS scoping, DOM encapsulation, and a declarative composition model out of the box. Custom elements provide the JS API and Shadow DOM supplies the HTML and CSS; together they make components that are robust, configurable, and reusable.
In practice, Shadow DOM solves several recurring problems:
- Isolated DOM: a component's internal nodes are invisible to page-wide queries like
document.querySelector(). - Scoped CSS: rules defined inside a shadow tree cannot leak out, and page styles cannot bleed into the component.
- Composition: components expose a declarative, markup-based interface for consumers.
- Simpler CSS: without global conflicts, you can use generic class names and straightforward selectors.
- Productivity: apps become collections of independent chunks rather than one massive global page.
What is shadow DOM?
The browser parses the HTML you author into a live, mutable tree of nodes called the DOM. Programs can query and alter those nodes; creating elements from JavaScript with document.createElement() is a standard operation.
Shadow DOM is normal DOM with two differences: how you create it and how it behaves relative to the rest of the document. A normal node is appended as a child of another element. A shadow tree, by contrast, is attached to an element but kept separate from its actual children. That scoped subtree is the shadow tree, and the element it's attached to is the shadow host. Whatever you place into the shadow tree — including <style> elements — becomes local to the host. That mechanism is what delivers CSS scoping.
Creating a shadow root
A shadow root is a document fragment attached to a host element. Calling element.attachShadow() creates it:
const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().
// header.shadowRoot === shadowRoot
// shadowRoot.host === header
In this case .innerHTML populates the shadow root, but the normal DOM APIs work just as well. Note that the specification restricts which elements can host a shadow tree. Elements that the browser itself provides with an internal shadow DOM — such as <textarea> and <input> — cannot host one, nor can elements where it simply makes no semantic sense, like <img>.
The value is most apparent in custom elements. A component attaches shadow DOM to itself inside its constructor(), and the CSS written into that shadow root stays scoped to the component instance:
// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
// Attach a shadow root to <fancy-tabs>.
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
<div id="tabs">...</div>
<div id="panels">...</div>
`;
}
...
});
Composition with slots
Composition is how the platform builds flexible widgets. Native elements like <select>, <details>, <form>, and <video> accept certain children and give them special behavior: <option> elements become dropdown items, <summary> becomes the expandable arrow, and <source> elements configure video playback without being rendered directly. Shadow DOM brings that same model to your own elements.
Two new terms matter here:
- Light DOM: the markup a consumer of your component writes. It lives outside the shadow tree and represents the element's actual children.
- Shadow DOM: the markup a component author writes, defining internal structure, scoped styles, and where consumer markup renders.
When the browser distributes light DOM into the shadow tree, the result is a flattened DOM tree — what you see rendered and what shows up in DevTools.
The <slot> element makes this composition work. Slots are placeholders inside your shadow tree that consumer markup can fill. They don't physically move nodes; they render incoming elements at another location. A component can define zero or more slots, name them for different purposes, and provide fallback content when the consumer provides nothing:
<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>
<slot>fallback content</slot> <!-- default slot with fallback content -->
<slot> <!-- default slot entire DOM tree as fallback -->
<h2>Title</h2>
<summary>Description text</summary>
</slot>
Named slots create specific, referenced holes in the shadow DOM. The <fancy-tabs> component, for instance, declares <slot name="title"> and <slot name="panel"> inside its shadow root. Component users then write:
<fancy-tabs>
<button slot="title">Title</button>
<button slot="title" selected>Title 2</button>
<button slot="title">Title 3</button>
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</fancy-tabs>
<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
<h2 slot="title">Title</h2>
<section>content panel 1</section>
<h2 slot="title" selected>Title 2</h2>
<section>content panel 2</section>
<h2 slot="title">Title 3</h2>
<section>content panel 3</section>
</fancy-tabs>
The component handles different child configurations while the flattened tree stays consistent — the same switch from <button> to <h2> works because the component was authored to accept distinct child types, just like the native <select> element.
Styling Web Components
Shadow DOM unlocks powerful styling options for web components. The main page, the component itself, or the component’s users can all contribute styles, depending on how the component is built.
Scoped CSS and Component-Defined Styles
The most significant feature of shadow DOM is scoped CSS. This means that CSS selectors from the outer page do not apply inside your component, and styles defined within the component do not leak out. They are strictly scoped to the host element.
Because CSS selectors inside shadow DOM apply locally to your component, you can safely use common id and class names without worrying about conflicts elsewhere on the page. This encourages simpler, more performant selectors.
Example - styles defined in a shadow root are local:
#shadow-root
<style>
#panels {
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
background: white;
...
}
#tabs {
display: inline-flex;
...
}
</style>
<div id="tabs">
...
</div>
<div id="panels">
...
</div>
Stylesheets are also scoped to the shadow tree:
#shadow-root
<link rel="stylesheet" href="styles.css">
<div id="tabs">
...
</div>
<div id="panels">
...
</div>
Consider how the native <select> element can render a multi-select widget when the multiple attribute is present:
<select multiple>
<option>Do</option>
<option selected>Re</option>
<option>Mi</option>
<option>Fa</option>
<option>So</option>
</select>
<select> styles itself differently based on its attributes. Web components can accomplish the same using the :host selector.
Example - a component styling itself:
<style>
:host {
display: block; /* by default, custom elements are display: inline */
contain: content; /* CSS containment FTW. */
}
</style>
One caveat with :host: rules from the parent page have higher specificity than :host rules defined inside the element itself, meaning outside styles take precedence. This is intentional, allowing users to override top-level styling from the outside. Note also that :host only works within a shadow root context.
The functional form :host(<selector>) allows you to target the host element if it matches a given <selector>—useful for packaging behaviors that react to user interaction or for styling internal nodes based on host state:
<style>
:host {
opacity: 0.4;
will-change: opacity;
transition: opacity 300ms ease-in-out;
}
:host(:hover) {
opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
background: grey;
pointer-events: none;
opacity: 0.4;
}
:host(.blue) {
color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>
Context and Theming
The :host-context(<selector>) pseudo-class matches the component if it or any of its ancestors matches <selector>. A common application is theming based on a component’s surroundings. For instance, many sites apply theming by adding a class to the <html> or <body>:
<body class="darktheme">
<fancy-tabs>
...
</fancy-tabs>
</body>
:host-context(.darktheme) would then style an <fancy-tabs> element that is a descendant of a .darktheme container:
:host-context(.darktheme) {
color: white;
background: black;
}
While :host-context() is handy for theming, a more robust approach offers style hooks through CSS custom properties.
Styling Distributed Nodes
The ::slotted(<compound-selector>) pseudo-element selects nodes distributed into a <slot>.
For illustration, consider a custom name badge component:
<name-badge>
<h2>Eric Bidelman</h2>
<span class="title">
Digital Jedi, <span class="company">Google</span>
</span>
</name-badge>
Its shadow DOM can style the user’s provided <h2> and .title:
<style>
::slotted(h2) {
margin: 0;
font-weight: 300;
color: red;
}
::slotted(.title) {
color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
text-transform: uppercase;
}
*/
</style>
<slot></slot>
Recall that <slot>s render light DOM nodes without moving them. Distributed nodes therefore receive styles that applied before distribution and can additionally pick up styles defined within the shadow DOM.
Here’s a more complete <fancy-tabs> example using two slots:
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>
#panels {
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
background: white;
border-radius: 3px;
padding: 16px;
height: 250px;
overflow: auto;
}
#tabs {
display: inline-flex;
-webkit-user-select: none;
user-select: none;
}
#tabsSlot::slotted(*) {
font: 400 16px/22px 'Roboto';
padding: 16px 8px;
...
}
#tabsSlot::slotted([aria-selected="true"]) {
font-weight: 600;
background: white;
box-shadow: none;
}
#panelsSlot::slotted([aria-hidden="true"]) {
display: none;
}
</style>
<div id="tabs">
<slot id="tabsSlot" name="title"></slot>
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
`;
In this example, there is a named slot for tab titles and another for tab panel content. Upon tab selection, the component bolds the chosen title and reveals the corresponding panel, accomplished by selecting distributed nodes with the selected attribute—managed by the custom element’s JavaScript.
External Styling and Style Hooks
Styling a component from outside can be done in several ways. The most direct method uses the tag name as a selector:
fancy-tabs {
width: 500px;
color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
box-shadow: 0 3px 3px #ccc;
}
Outside styles always override styles inside shadow DOM. For instance, fancy-tabs { width: 500px; } takes precedence over :host { width: 650px;}.
To customize the internal appearance of a component, authors need to offer styling hooks via CSS custom properties. This works like <slot> but for styles—creating placeholders the user can override.
Example - <fancy-tabs> defining an overridable background color:
<!-- main page -->
<style>
fancy-tabs {
margin-bottom: 32px;
--fancy-tabs-bg: black;
}
</style>
<fancy-tabs background>...</fancy-tabs>
Within its shadow DOM:
:host([background]) {
background: var(--fancy-tabs-bg, #9E9E9E);
border-radius: 10px;
padding: 10px;
}
The component uses black as the supplied value; otherwise, it defaults to #9E9E9E.
Inside the Shadow API
Slots in JavaScript
The shadow DOM API provides tools for working with slots and distributed nodes, which is helpful when building custom elements.
The slotchange Event
The slotchange event fires when a slot’s distributed nodes change, such as when a user adds or removes children in light DOM:
const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
console.log('light dom children changed!');
});
To observe other light DOM mutations, a MutationObserver can be set up in your element’s constructor.
Finding Rendered Elements
To determine which elements are rendered by a particular slot, use slot.assignedNodes(). Passing the {flatten: true} option also returns any fallback content when no nodes are distributed.
Given this shadow DOM structure:
<slot><b>fallback content</b></slot>
| Usage | Call | Result |
|---|---|---|
| <my-component>component text</my-component> | slot.assignedNodes(); |
[component text] |
| <my-component></my-component> | slot.assignedNodes(); |
[] |
| <my-component></my-component> | slot.assignedNodes({flatten: true}); |
[<b>fallback content</b>] |
Determining the Assigned Slot
The reverse—finding which slot an element is assigned to—can be determined with element.assignedSlot.
Event Retargeting and the Event Model
When an event bubbles from shadow DOM, its target is maintained as the hosting component, preserving encapsulation. Some events do not propagate beyond the shadow boundary at all, however. Those that do include focus, mouse, wheel, input, keyboard, composition, and drag events.
Tip: For an open shadow tree, event.composedPath() returns the array of nodes the event traversed.
Using Custom Events
Unless created with the composed: true flag, custom DOM events fired internally in a shadow tree do not cross the boundary:
// Inside <fancy-tab> custom element class definition:
selectTab() {
const tabs = this.shadowRoot.querySelector('#tabs');
tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}
With composed: false (the default), listeners outside the shadow root won’t receive the event:
<fancy-tabs></fancy-tabs>
<script>
const tabs = document.querySelector('fancy-tabs');
tabs.addEventListener('tab-select', e => {
// won't fire if `tab-select` wasn't created with `composed: true`.
});
</script>
Managing Focus
Focus events follow the same encapsulation pattern, making it appear the focus originated from the hosting element. Clicking an internal <input> in a shadow root logically focuses the host <x-focus>:
<x-focus>
#shadow-root
<input type="text" placeholder="Input inside shadow dom">
With an open shadow root, the internally focused node can still be accessed:
document.activeElement.shadowRoot.activeElement // only works with open mode.
When custom elements nest, you must recurse through their shadow roots to find the true activeElement:
function deepActiveElement() {
let a = document.activeElement;
while (a && a.shadowRoot && a.shadowRoot.activeElement) {
a = a.shadowRoot.activeElement;
}
return a;
}
The delegatesFocus: true option changes focus behavior within the shadow tree:
- Clicking a non-focusable node inside the shadow DOM focuses the first focusable area.
- When an inner node gains focus,
:focusalso applies to the host element.
Example - the effect of delegatesFocus: true:
<style>
:focus {
outline: 2px solid red;
}
</style>
<x-focus></x-focus>
<script>
customElements.define('x-focus', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
const root = this.attachShadow({mode: 'open', delegatesFocus: true});
root.innerHTML = `
<style>
:host {
display: flex;
border: 1px dotted black;
padding: 16px;
}
:focus {
outline: 2px solid blue;
}
</style>
<div>Clickable Shadow DOM text</div>
<input type="text" placeholder="Input inside shadow dom">`;
// Know the focused element inside shadow DOM:
this.addEventListener('focus', function(e) {
console.log('Active element (inside shadow dom):',
this.shadowRoot.activeElement);
});
}
});
</script>
Result
This result occurs when <x-focus> itself is focused (via click, tab, or focus()), when “Clickable Shadow DOM text” is clicked, or when the internal <input> gains focus (including via autofocus).
With delegatesFocus: false, focus behavior differs:
delegatesFocus: false and the internal <input> is focused.
delegatesFocus: false and <x-focus>
gains focus (e.g. it has tabindex="0").
delegatesFocus: false and "Clickable Shadow DOM text" is
clicked (or other empty area within the element's shadow DOM is clicked).
Authoring Tips
Building on years of hands-on experience with web components, a few practical techniques can make authoring and debugging shadow DOM noticeably easier.
Contain Layout Costs
Since a component's layout and paint are typically self-contained, applying CSS containment on :host gives the browser permission to optimize rendering significantly.
<style> :host { display: block; contain: content; /* Boom. CSS containment FTW. */ } </style>
Reset Inherited Styles
Styles like background, color, font, and line-height inherit across the shadow boundary by default. To start components from a consistent base, reset those values when they cross into the shadow tree with all: initial;.
<style> div { padding: 10px; background: red; font-size: 25px; text-transform: uppercase; color: white; } </style> <div> <p>I'm outside the element (big/white)</p> <my-element>Light DOM content is also affected.</my-element> <p>I'm outside the element (big/white)</p> </div> <script> const el = document.querySelector('my-element'); el.attachShadow({mode: 'open'}).innerHTML = ` <style> :host { all: initial; /* 1st rule so subsequent properties are reset. */ display: block; background: white; } </style> <p>my-element: all CSS properties are reset to their initial value using <code>all: initial</code>.</p> <slot></slot> `; </script>
Find All Custom Elements on a Page
Locating every custom element in use requires recursively descending into the shadow roots of all elements on the page.
const allCustomElements = []; function isCustomElement(el) { const isAttr = el.getAttribute('is'); // Check for <super-button> and <button is="super-button">. return el.localName.includes('-') || isAttr && isAttr.includes('-'); } function findAllCustomElements(nodes) { for (let i = 0, el; el = nodes[i]; ++i) { if (isCustomElement(el)) { allCustomElements.push(el); } // If the element has shadow DOM, dig deeper. if (el.shadowRoot) { findAllCustomElements(el.shadowRoot.querySelectorAll('*')); } } } findAllCustomElements(document.querySelectorAll('*'));
Declare Structure with Templates
Rather than assigning content via .innerHTML, a static <template> is a cleaner way to define a component's initial shadow tree. The pattern is covered in Google's "Custom elements" guide.
Compatibility and Browser Support
Older versions of Chrome and Opera shipped the earlier v0 proposal, which relied on element.createShadowRoot rather than v1's element.attachShadow. Blink will run both branches in parallel for now — code calling the legacy method still gets a v0 root, so existing components continue to work untouched. Comparing the two revisions is instructive: v0 introduced the concept, but v1's slot-based composition and cleaner API are what the platform standardized on.
Shadow DOM v1 now ships in Chrome 53, Opera 40, Safari 10, Firefox 63, with Edge development underway. Feature detection is a direct check for the v1 API:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
The Polyfill Route
Until native coverage is universal, the shadydom and shadycss projects provide the v1 feature set today. The former mimics the DOM scoping Shadow DOM provides; the latter shims CSS custom properties and style encapsulation.
Installing both is straightforward:
bower install --save webcomponents/shadydom bower install --save webcomponents/shadycss
And activating them requires only a couple of lines:
function loadScript(src) { return new Promise(function(resolve, reject) { const script = document.createElement('script'); script.async = true; script.src = src; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } // Lazy load the polyfill if necessary. if (!supportsShadowDOMV1) { loadScript('/bower_components/shadydom/shadydom.min.js') .then(e => loadScript('/bower_components/shadycss/shadycss.min.js')) .then(e => { // Polyfills loaded. }); } else { // Native shadow dom v1 support. Go to go! }
For style shimming specifics, the shadycss README walks through scoping and usage details.
Answering Common Questions
Is Shadow DOM usable now? Yes, via polyfill on browsers without native support.
Is it a security boundary? No. It is a lightweight mechanism for CSS and DOM scoping. Genuine isolation still demands an <iframe>.
Must a custom element use it? Not required, but doing so provides scoping and composition benefits that are hard to achieve otherwise.
Open versus closed roots? The trade-off — script-accessible bright shadow roots versus stricter encapsulation — hinges on whether authoring defensive code is acceptable, a decision covered in the section on closed roots.
Additional Reading
- Shadow DOM v1 versus v0 differences
- WebKit's introduction to its slot-based API
- Philip Walton on web components and the future of modular CSS
- Google's guide to custom elements, the companion to this article
- Official Shadow DOM v1 and Custom Elements v1 specifications
Shadow DOM finally delivers CSS scoping, DOM encapsulation, and composition as first-class primitives, eliminating prior hacks around <iframe>s. It may be a complex spec, but it's worth mastering — a solid component author keeps both the API details and its practical debugging tricks close at hand.



