Shadow DOM
Shadow DOM exists for one reason: encapsulation. It lets a single element carry a private DOM tree of its own — a tree that outside JavaScript can’t stumble into, whose styles stay put and don’t leak, and whose ids never collide with the rest of the page.
Think of a component as a sealed box. The page can see the box and place it wherever it likes, but the wiring inside stays sealed away. That’s the whole idea.
Built-in shadow DOM
Ever wonder how the browser draws and styles a complicated control? Take the range slider:
That thumb, that track, the fill — none of it is a single pixel-painted widget. The browser builds it out of ordinary DOM and CSS, then hides that structure from you. It’s there; you just can’t see it by default.
You can see it in developer tools. In Chrome, open DevTools settings and turn on Show user agent shadow DOM. Once that’s enabled, <input type="range"> reveals its inner scaffolding:
Everything nested under #shadow-root is the element’s shadow DOM.
You cannot reach these internal elements with normal JavaScript. document.querySelector won’t find them, and they don’t show up as children of the input. They’re sealed off — which is exactly the point.
In DevTools you might spot a pseudo attribute on those internal parts. It’s non-standard, kept alive for historical reasons, and it’s how you target some of these subelements with CSS:
<style>
/* recolor the slider track */
input::-webkit-slider-runnable-track {
background: red;
}
</style>
<input type="range">
From here on we use the modern, standardized shadow DOM described in the DOM specification and related specs.
Shadow tree
An element can host two kinds of DOM subtree:
- Light tree — an ordinary DOM subtree built from HTML children. Every tree you’ve dealt with in earlier chapters was a light tree.
- Shadow tree — a hidden subtree that never appears in the HTML source and stays out of reach.
When an element has both, the browser renders only the shadow tree. You can still stitch the two together, so light children appear inside slots in the shadow tree — that’s the subject of Shadow DOM slots, composition.
Shadow trees pair naturally with custom elements: hide the internal markup, ship styles that only apply inside, and expose a clean tag to the page.
Here’s a <show-hello> element that keeps its markup in a shadow tree:
<script>
customElements.define('show-hello', class extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML = `<p>
Hello, ${this.getAttribute('name')}
</p>`;
}
});
</script>
<show-hello name="Maya"></show-hello>
In Chrome DevTools the rendered DOM tucks all the content under #shadow-root:
The call elem.attachShadow({mode: …}) is what creates the shadow tree. Two rules govern it:
- One shadow root per element. You can’t attach a second.
- Only certain hosts are allowed. The element must be a custom element, or one of:
article,aside,blockquote,body,div,footer,h1–h6,header,main,nav,p,section, orspan. Elements like<img>can’t host a shadow tree — attemptingattachShadowon them throws.
Here’s a live version. Type a name, add a badge, and each <name-badge> builds its own sealed shadow tree — markup and styles that the page never sees:
open vs. closed
The mode option sets how reachable the shadow tree is from outside. It takes one of two values:
"open"— the shadow root is exposed aselem.shadowRoot. Any script that can reach the element can also reach its shadow tree."closed"—elem.shadowRootis alwaysnull. The only handle on the tree is the referenceattachShadowreturned, which a component typically keeps private inside its class. Native shadow trees, like the one inside<input type="range">, are closed, so there’s no way in.
See the difference for yourself. Attach a root in each mode and watch what the host’s shadowRoot property reports — while the reference attachShadow returned keeps working either way:
The shadow root that attachShadow returns behaves much like an element. You populate it with innerHTML, or with DOM methods like append:
const shadow = elem.attachShadow({mode: 'open'});
shadow.append(document.createElement('p'));
The element that owns a shadow root is its host, reachable through the root’s host property:
// assuming {mode: "open"}, otherwise elem.shadowRoot is null
alert(elem.shadowRoot.host === elem); // true
the host
the shadow tree
Encapsulation
The shadow tree is firmly walled off from the main document. Two guarantees make that concrete:
- Selectors from the outside can’t see in.
document.querySelectornever returns shadow elements. As a side effect, ids inside a shadow tree only need to be unique within that tree — they can freely clash with ids in the page or in other shadow trees. - Styles don’t cross the boundary in either direction. The document’s stylesheets don’t reach into the shadow tree, and the shadow tree’s styles don’t leak back out. Each side has its own scope.
nothing crosses
Here it is in code:
<style>
/* document style won't apply to the shadow tree inside #elem (1) */
p { color: red; }
</style>
<div id="elem"></div>
<script>
elem.attachShadow({mode: 'open'});
// shadow tree has its own style (2)
elem.shadowRoot.innerHTML = `
<style> p { font-weight: bold; } </style>
<p>Hello, Maya!</p>
`;
// <p> is only visible from queries inside the shadow tree (3)
alert(document.querySelectorAll('p').length); // 0
alert(elem.shadowRoot.querySelectorAll('p').length); // 1
</script>
Reading it back:
- The document’s
p { color: red }never touches the shadow<p>. - But the shadow tree’s own
p { font-weight: bold }does apply — inside styles work. - To find elements in the shadow tree you must query from the tree:
elem.shadowRoot.querySelectorAll('p')finds it;document.querySelectorAll('p')finds nothing.
Watch both guarantees at once. The page’s stylesheet paints every <p> red — but the shadow <p> stays put, and a document-wide querySelector never counts it:
Notice which paragraph turned red: only the page’s own. The shadow tree’s <p> ignores the document rule and follows its own bold style instead.
References
- DOM: https://dom.spec.whatwg.org/#shadow-trees
- Compatibility: https://caniuse.com/#feat=shadowdomv1
- Shadow DOM shows up across many specs — for instance, the DOM Parsing spec is what gives a shadow root its
innerHTML.
Summary
Shadow DOM builds a DOM tree that belongs to one component and nothing else.
shadowRoot = elem.attachShadow({mode: open|closed})creates the shadow tree forelem. Withmode: "open"it’s reachable aselem.shadowRoot; with"closed"that property staysnulland only the returned reference works.- Fill
shadowRootwithinnerHTMLor DOM methods likeappend.
Elements inside a shadow tree:
- keep their own id space, free to reuse ids from the page;
- stay invisible to selectors run from the main document, such as
document.querySelector; - take styles only from within the shadow tree, never from the document.
When a shadow tree exists, the browser renders it in place of the element’s light DOM (its regular children). The next step, combining the two so light children flow into shadow slots, is covered in Shadow DOM slots, composition.