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.

Light DOM (the page)
document
<body>
<my-widget>  ← host
Shadow DOM (private)
#shadow-root
<style>…</style>
<p>Hello</p>
not in the HTML source
A host element carries a hidden shadow tree alongside — but separate from — the main document.

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:

Elements<input type=“range”>#shadow-root (user-agent)<div pseudo=“-webkit-slider-runnable-track”><div pseudo=“-webkit-slider-thumb”></div></#shadow-root></input>
With user-agent shadow DOM shown, a range input reveals the hidden track and thumb scaffolding under its shadow root.

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
the regular children
written in HTML
reachable by selectors
Shadow tree
the hidden children
not in the HTML source
isolated from the page
Two subtrees an element can own. When both exist, only the shadow tree renders.
  1. Light tree — an ordinary DOM subtree built from HTML children. Every tree you’ve dealt with in earlier chapters was a light tree.
  2. 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:

Elements<show-hello name=“Maya”>#shadow-root (open)<p> Hello, Maya </p></#shadow-root></show-hello>
The rendered tree: the show-hello host owns a shadow root, and the paragraph lives inside it, not in the HTML source.

The call elem.attachShadow({mode: …}) is what creates the shadow tree. Two rules govern it:

  1. One shadow root per element. You can’t attach a second.
  2. Only certain hosts are allowed. The element must be a custom element, or one of: article, aside, blockquote, body, div, footer, h1h6, header, main, nav, p, section, or span. Elements like <img> can’t host a shadow tree — attempting attachShadow on 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:

interactiveA custom element with its own shadow tree

open vs. closed

The mode option sets how reachable the shadow tree is from outside. It takes one of two values:

mode: “open”
elem.shadowRoot → the root
any code can reach in
mode: “closed”
elem.shadowRoot → null
only the returned ref works
mode controls whether the outside world can reach the shadow root through the host.
  • "open" — the shadow root is exposed as elem.shadowRoot. Any script that can reach the element can also reach its shadow tree.
  • "closed"elem.shadowRoot is always null. The only handle on the tree is the reference attachShadow returned, 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:

interactiveopen exposes shadowRoot; closed hides it

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
elem
the host
.shadowRoot →
← .host
#shadow-root
the shadow tree
host and shadowRoot point back at each other (shadowRoot only exposed when mode is open).

Encapsulation

The shadow tree is firmly walled off from the main document. Two guarantees make that concrete:

  1. Selectors from the outside can’t see in. document.querySelector never 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.
  2. 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.
Document
p { color: red }
querySelectorAll(‘p’) → 0 inside
⟂ boundary ⟂
nothing crosses
#shadow-root
p { font-weight: bold }
its own <p> renders bold, not red
The shadow boundary blocks selectors and styles from crossing.

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:

  1. The document’s p { color: red } never touches the shadow <p>.
  2. But the shadow tree’s own p { font-weight: bold } does apply — inside styles work.
  3. 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:

interactiveStyles and selectors stop at the boundary

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

Summary

Shadow DOM builds a DOM tree that belongs to one component and nothing else.

  1. shadowRoot = elem.attachShadow({mode: open|closed}) creates the shadow tree for elem. With mode: "open" it’s reachable as elem.shadowRoot; with "closed" that property stays null and only the returned reference works.
  2. Fill shadowRoot with innerHTML or DOM methods like append.

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.