skip to content

Shadow DOM and Encapsulation

You will learn what a shadow root actually walls off — styles, selectors, and event targets — and what still leaks through. Interviewers use open-vs-closed and event retargeting as the discriminating follow-ups.

on this pageshow

questions

5

In the DOM, what does calling element.attachShadow({ mode: 'open' }) do, and what does the resulting shadow boundary actually encapsulate?

level: juniorimportance: must knowfreq 70%

answer

  1. a second tree hanging off the host
  2. selectors stop at the boundary, both ways
  3. ids are scoped per root
  4. only some elements may host one

basics

~20 s

attachShadow creates a ShadowRoot: a separate DOM subtree rendered in place of the element's own children. Its boundary scopes selectors both ways, so outer CSS and document.querySelector cannot reach inside, and styles defined inside cannot leak out.

solid answer

~50 s

`attachShadow` attaches a `ShadowRoot` to the element and returns it; in open mode the element also exposes it as `el.shadowRoot`, and the root points back through `shadowRoot.host`. From then on the browser renders the shadow tree in place of the element's own children, and the boundary scopes name lookups in both directions: a selector written in the document — including `document.querySelector` — does not match nodes inside the root, and a `<style>` inside the root cannot match anything outside it. IDs and classes are scoped to that tree, so many instances can reuse the same id safely. Plenty still crosses: the host is an ordinary element the page styles and lays out, composed events propagate out (with their target retargeted), and an open root is reachable from any same-page script. `attachShadow` is only allowed on valid custom-element names and a fixed list of plain elements such as `div`, `span`, `p` and `section`.

code

javascript · 10 lines
javascript
const host = document.createElement('div');
document.body.append(host);

const root = host.attachShadow({ mode: 'open' });
root.innerHTML = '<style>p { color: crimson }</style><p id="label">inside</p>';

console.log(document.querySelector('#label'));      // null - query stops at the boundary
console.log(root.querySelector('#label').id);        // "label"
console.log(root.host === host);                     // true
console.log(host.shadowRoot === root);               // true (open mode)

go deeper

for a junior

Be ready to say plainly that attachShadow makes a second, scoped DOM tree, and that a document-level querySelector or CSS rule will not reach into it. Knowing you must go through el.shadowRoot is most of the answer.

for a middle

Explain the mechanics: the root is a DocumentFragment linked by host, the shadow tree renders in place of the light children, and ids and selectors are scoped per root while the host itself stays an ordinary element in the page.

for a senior

Show where encapsulation stops. Be able to list what still crosses — events, layout, focus order, script access through an open root — and explain how that shapes debugging when a component looks broken only in one host page.

for a principal

Own the tradeoff. Argue when platform-level scoping is worth the cost of routing every query, global stylesheet and test selector across the boundary, versus build-time scoping that keeps one flat tree.

## What the call creates `Element.attachShadow(init)` creates a `ShadowRoot` — a special `DocumentFragment` — and attaches it to the element, which is then called the *shadow host*. The call returns the root, and `mode` is a required member of the init dictionary: ```js class MyCard extends HTMLElement { constructor() { super(); const root = this.attachShadow({ mode: 'open' }); root.innerHTML = `<style>p { color: crimson }</style><p id="label">Hello</p>`; } } customElements.define('my-card', MyCard); ``` The root is not a child node in the normal sense: `host.childNodes` does not contain it, and `shadowRoot.parentNode` is `null`. The link back is `shadowRoot.host`, and with `mode: 'open'` there is also a forward link, `host.shadowRoot`. Two trees now exist for one element. The nodes the page author wrote between the tags are the *light DOM*; the nodes inside the root are the *shadow DOM*. Once a root is attached, the browser renders the shadow tree in the host's place — the light children are still in the DOM and still scriptable, but they are only painted where the shadow tree explicitly makes room for them. ## Where attachShadow is allowed Not every element accepts a shadow root. The host must either have a valid custom element name (a dash in it, like `my-card`) or be one of a fixed list of ordinary elements: `article`, `aside`, `blockquote`, `body`, `div`, `footer`, `h1`–`h6`, `header`, `main`, `nav`, `p`, `section`, `span`. Calling it on `<input>`, `<button>`, `<img>` or `<textarea>` throws a `NotSupportedError`, because those elements already have browser-internal shadow trees or a rendering model that cannot host one. Calling it twice on the same element throws as well. ## What the boundary blocks The boundary is a **scoping** boundary for names and selectors, and it works in both directions. *Selectors do not cross inward.* A document stylesheet rule such as `.card p { color: blue }` never matches a `<p>` inside a shadow root, no matter how specific it is. That is why the component's own `<style>` block is reliable: nothing in the host page can accidentally restyle its internals. *Selectors do not cross outward.* The `p { color: crimson }` above styles only paragraphs inside that one root. Two components can both ship a bare `p` rule without colliding. *DOM queries do not cross inward.* `document.querySelector('#label')` returns `null` for the example above; you must go through the root: `document.querySelector('my-card').shadowRoot.querySelector('#label')`. The same applies to `document.getElementById`, and to `Element.closest()` called from inside the shadow tree — it walks up to the shadow root and stops, because the root is not an element. *IDs are scoped.* Every shadow root has its own id namespace, so a hundred instances of a component can each contain `#label` without conflict — the classic reason component authors reach for shadow DOM at all. ## What still crosses Encapsulation is not isolation, and interviewers probe exactly here. - **The host is a normal element.** The page selects it by tag or class, gives it margins, and puts it in the layout and focus order like anything else. Note that a custom element is `display: inline` by default until something says otherwise. - **Events escape.** Most UI events are dispatched with `composed: true`, so a click inside the shadow tree still bubbles out to `document` — with `event.target` rewritten to the host so the outside never learns the internal structure. - **Script still reaches in.** Open mode is a convention, not a wall: any script on the page can read `el.shadowRoot` and rewrite the internals. - **The tree is real DOM.** Shadow nodes participate in rendering, hit-testing and the accessibility tree; they are not a rendering trick. ## Why it exists Before shadow DOM, component styles were guarded by naming conventions — BEM prefixes, hashed class names from a bundler — and internal structure was guarded by nothing at all. Shadow DOM moves both guarantees into the platform: the browser, not a build step, is what stops the page's `p { margin: 0 }` from reaching your widget's internals. The price is that everything which used to be trivially global — a document-wide query, a design-system stylesheet, a `querySelector` in an end-to-end test — now has to be explicitly routed across the boundary.

  • What happens to the element's existing children once you attach a shadow root to it?
    They stay in the DOM as the element's light DOM — still in `host.childNodes`, still scriptable — but they stop being rendered on their own. The shadow tree is rendered in their place, and the light children only appear where the shadow tree provides a place for them. Nothing is deleted; the rendering just no longer follows `childNodes`.
  • Why does attachShadow throw on an <input> element?
    `attachShadow` is restricted to valid custom-element names and a fixed list of plain elements (`div`, `span`, `p`, `section`, `header` and a handful more). Elements like `input`, `button`, `textarea` and `img` already carry browser-internal shadow trees or have a rendering model that cannot host one, so the call raises a `NotSupportedError`.
  • Does the shadow boundary hide the component from assistive technology?
    No. Shadow nodes are real DOM and are exposed in the accessibility tree normally — a heading or a button inside a shadow root is announced like any other. What the boundary scopes is CSS selector matching and DOM lookups, not semantics or rendering.

A shadow root is like a room with its own light switch: the page can move the room around and knock on the door, but the wiring inside is on a separate circuit.

saying these in an interview costs you the question

  • Thinks shadow DOM hides markup from view-source or devtools
  • Says document.querySelector finds nodes inside an open root
  • Believes attaching a shadow root deletes the light children
  • Claims shadow DOM is only a styling trick with no real DOM
  • Tries to attachShadow on an input or button and expects it to work

context

open as a page

A click on a <button> inside a component's open shadow root is handled by a listener on the shadow host, but event.target is the host element rather than the button. Why does the browser report it that way, and how do you find the element that was really clicked?

level: middleimportance: must knowfreq 54%

basics

~20 s

The browser retargets an event's target as it crosses a shadow boundary, rewriting it to the nearest ancestor in the same tree as the listening node, so encapsulation holds. Use event.composedPath()[0] to reach the real innermost element.

open as a page

What is the difference between attaching a shadow root with attachShadow({ mode: 'open' }) and attachShadow({ mode: 'closed' }), and is closed mode a security boundary?

level: middleimportance: must knowfreq 58%

basics

~20 s

Open mode exposes the root as element.shadowRoot; closed mode makes that property return null, so only the code holding the returned root can reach inside. Closed is a discouragement, not a security boundary — same-page script can still get in.

open as a page

Inside a shadow root's own stylesheet, what do the :host and :host-context() selectors match, and why can't the component just write its own tag name as the selector instead?

level: middleimportance: should knowfreq 40%

basics

~20 s

:host matches the shadow host from inside its shadow tree, and :host(sel) matches only when the host also matches sel. :host-context(sel) matches when the host or any ancestor matches sel. A plain tag selector fails because the host lives outside the tree.

open as a page

A custom element clones a <style> block into every instance's shadow root, and one page renders several hundred instances. What does that cost, and what changes if the component assigns to shadowRoot.adoptedStyleSheets instead?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Every shadow root gets its own style element and its own CSSOM stylesheet object, so rules are duplicated per instance in memory and on every DOM insertion. adoptedStyleSheets lets many roots share one constructed CSSStyleSheet, parsed once and updated in one place.

open as a page