DOMGENERALSPEC-EVOLVINGBROWSER-SPECIFIC

Shadow DOM and the Composed Tree

A second tree attached to a host element: styles do not cross, queries do not cross, events retarget — and id-based accessibility relationships break in ways nothing warns you about.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What does a shadow root actually isolate, and what still crosses the boundary?

The user intent

A team wants a component they can drop into any page — their own, another team's, a customer's — and have it look and behave the same way in all of them.

The obvious build

Attach a shadow root and the component is encapsulated. Nothing outside can affect it and nothing inside can leak out, so it is safe anywhere.

Why it breaks

Inherited properties cross freely. color, font-family, line-height, direction and every CSS custom property flow into the shadow tree from the host's context, which is usually what you want and always a surprise the first time (Inheritance and Computed Style).

How it breaks in a real browser
  • Inherited properties cross freely. color, font-family, line-height, direction and every CSS custom property flow into the shadow tree from the host's context, which is usually what you want and always a surprise the first time (Inheritance and Computed Style).
  • document.querySelector cannot see inside, which breaks every analytics script, every test selector and every "just grab the element" utility your team already has (Queries, Live Collections and Stale References).
  • aria-labelledby, aria-describedby, for and aria-activedescendant resolve ids within a single tree. A label in the light DOM cannot name a control inside a shadow root, and nothing reports the failure (The Rules of ARIA).
  • Events retarget at the boundary: a listener outside sees event.target as the host element, not the inner button that was clicked. Delegation written against inner structure silently stops matching (Event Delegation).
  • A closed shadow root is not a security boundary. Same-origin script has several routes to the same nodes, and it never protected against injection in the first place (The Browser Security Model).
  • Global stylesheets do not apply inside, so a design system delivered as a global stylesheet stops working exactly where a component most needs it (Design Systems).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A shadow root is a separate document fragment attached to a host element. The host's own children are the light DOM; the shadow root's children are the shadow tree.
  • A <slot> in the shadow tree is a placeholder where light DOM children are distributed. The children are not moved — they stay in the light tree and are rendered in the slot's position.
  • What is rendered is the composed tree: the shadow tree with slotted light DOM spliced in at each slot. Style, layout, paint and the accessibility tree are all computed from the composed tree, not from either tree alone.
  • Style scoping works in both directions: a selector in the outer document cannot match an element inside a shadow root, and a selector inside cannot match anything outside. :host, ::slotted() and ::part() are the three deliberate holes.
  • Inheritance is not scoping. Inherited properties and custom properties pass through the boundary normally, which is the intended mechanism for theming a component from outside (Custom Properties).
  • Event retargeting: as an event crosses the boundary on its way up, its target is rewritten to the host so outside code sees a coherent tree. event.composedPath() returns the real path, and composed: false events — including slotchange and some UI events — do not cross at all (How an Event Is Dispatched).
  • Focus works normally inside, but document.activeElement returns the host. delegatesFocus: true on the shadow root makes focusing the host focus the first focusable element inside it.
  • open versus closed changes only whether host.shadowRoot returns the root. It is an encapsulation setting with a discipline benefit and no security property.

What this makes the browser do

And which of it is avoidable.

  • Maintaining two trees plus the composed tree, and recomputing distribution when slotted children change.
  • Scoping style resolution per shadow root. This is generally *cheaper* than a global stylesheet, because the candidate set for each selector is bounded by the root (Selector Matching Cost).
  • Retargeting each event as it crosses a boundary — a small per-event cost that scales with the number of boundaries an event traverses.
  • Duplicating stylesheet parsing per instance unless the sheets are shared. adoptedStyleSheets exists specifically so one parsed CSSStyleSheet can be reused across every instance rather than re-parsed per element.
  • Avoidable: one <style> element per component instance on a page with hundreds of instances, which parses the same CSS hundreds of times.

Three trees, one rendering

The mental model that makes everything else follow: there are two trees you write and a third the browser composes from them. Style, layout, paint, focus order and the accessibility tree are all computed from that third tree, which is why slotted content is announced and rendered where the slot is rather than where the markup is.

It also explains the asymmetry people find confusing. Selectors and queries operate on the tree they are written in, so they do not cross. Inheritance, focus order and accessibility operate on the composed tree, so they do.

  • Crosses the boundary: inherited properties, CSS custom properties, composed events (bubbling up, retargeted), focus order, the accessibility tree, :host and ::part() styling.
  • Does not cross: ordinary selectors in either direction, querySelector/querySelectorAll, getElementById, id-based ARIA and label references, and composed: false events.
  • Crosses only if you build it: form participation (formAssociated plus ElementInternals), host semantics, and any global preference rule such as reduced motion.
Light DOM, shadow tree, composed tree
childrenattachShadowscoped to this rootdistributed into <slot>reachesblocked<x-card> host elementadoptedStyleSheets (scoped)Outer document selectors + queries#shadow-rootLight DOM: <h2>Title</h2>, <p>Body</p>Shadow tree: <div part="card"> <slot>Composed tree — what is renderedStyle + layout + paintAccessibility tree
UserLLMAgentToolDataDecisionHumanGuardrail

A component that gets the boundary right

Four decisions in this element are the ones that matter, and each corresponds to something the boundary would otherwise break: a shared constructed stylesheet so styles are parsed once, delegatesFocus so the host behaves like a control, a real button inside rather than a styled div, and an accessible name that lives on the same side of the boundary as the control it names.

Notice what is deliberately exposed. Two custom properties and one part are the entire public styling surface — small enough to keep stable, and everything else is genuinely private (What a Component Owes Its Caller).

A custom element with a shadow root
1// Parsed once for the whole page, adopted by every instance.
2const sheet = new CSSStyleSheet()
3sheet.replaceSync(`
4 :host { display: inline-block; --_bg: var(--toggle-bg, #eee); }
5 :host([hidden]) { display: none; }
6 button {
7 background: var(--_bg);
8 color: inherit; /* inherited properties cross the boundary */
9 font: inherit; /* so the host page's typography still applies */
10 }
11 @media (prefers-reduced-motion: reduce) {
12 button { transition: none; } /* the outer document's rule does NOT reach here */
13 }
14`)
15
16class XToggle extends HTMLElement {
17 static observedAttributes = ['pressed', 'label']
18 #internals = this.attachInternals()
19 #button: HTMLButtonElement
20
21 constructor() {
22 super()
23 // delegatesFocus: host.focus() and label clicks focus the inner control
24 const root = this.attachShadow({ mode: 'open', delegatesFocus: true })
25 root.adoptedStyleSheets = [sheet]
26
27 // A real button: keyboard activation, default action and role for free.
28 this.#button = document.createElement('button')
29 this.#button.type = 'button'
30 this.#button.part.add('control') // the one restyleable hook
31 root.append(this.#button)
32
33 this.#button.addEventListener('click', () => {
34 this.pressed = !this.pressed
35 // composed: true so the outer page sees it — retargeted to the host
36 this.dispatchEvent(new CustomEvent('toggle', {
37 bubbles: true, composed: true, detail: { pressed: this.pressed },
38 }))
39 })
40 }
41
42 attributeChangedCallback() {
43 // The name must live inside the boundary — an outer aria-labelledby
44 // pointing at an id in here resolves to nothing, silently.
45 this.#button.textContent = this.getAttribute('label') ?? 'Toggle'
46 this.#button.setAttribute('aria-pressed', String(this.pressed))
47 this.#internals.ariaPressed = String(this.pressed) // host-level default
48 }
49
50 get pressed() { return this.hasAttribute('pressed') }
51 set pressed(v: boolean) { this.toggleAttribute('pressed', v) }
52}
53customElements.define('x-toggle', XToggle)

The two lines doing the most work are delegatesFocus and adoptedStyleSheets. Without the first, focus and label clicks stop at the host; without the second, every instance re-parses the same CSS.

The accessibility contract at the boundary

This is where shadow DOM is most likely to produce a bug nobody catches, because the failure is silent in every direction: no console warning, no visual difference, and an automated check that reports a missing name without saying that a reference existed and did not resolve.

The specification below is what a host that stands in for a single control owes. The pattern generalises: whatever relationships the component needs, both ends must be in the same tree, or expressed on the host, or established through element references rather than ids.

accessibility specCustom element wrapping a single native control (`<x-toggle>`)Host semantics for a shadow-encapsulated control

semantics A real <button type="button"> inside the shadow root carries the role and the keyboard behaviour. ElementInternals sets a role and ARIA state on the host as a default that a consumer can still override with attributes.

TabReaches the component exactly once. With delegatesFocus, focus lands on the inner button rather than the host.
Space / EnterActivates the inner button through its native default action — nothing to implement (Keyboard Events).
Shift + TabLeaves the component; focus order follows the composed tree, so a slot placed late in the shadow tree tabs late regardless of where its content sits in the light DOM.
Focus
  • host.focus() must focus the control, which is what delegatesFocus: true provides.
  • document.activeElement returns the host from outside; root.activeElement gives the real focused node inside. Focus-trap utilities that only query document will not find it (Focus Management).
  • A <label for> in the outer document cannot target the inner control. Either wrap the host in the label, or give the host an aria-label.
Announces
  • The accessible name, from aria-label on the host or from text content inside the shadow tree — never from an outer aria-labelledby pointing at an inner id.
  • The pressed state, from aria-pressed on the inner button and mirrored on the host through ElementInternals for consumers who inspect the host.
  • Slotted light-DOM content, announced in its composed position, because the accessibility tree is built from the composed tree.

usually broken by The pattern invites exactly one mistake: writing aria-labelledby="my-label" on the host, pointing at an id inside the shadow root — or the reverse. Id references resolve within a single tree and fail silently across the boundary, producing a control with no accessible name that looks entirely correct in the markup (The Rules of ARIA).

How to build it

Most important first.

  • Reach for a shadow root when you genuinely need style isolation in a page you do not control — an embedded widget, a design-system primitive used across teams, a browser extension's UI. Do not reach for it as the default for an application component (Drawing Component Boundaries).
  • Design the theming surface deliberately: expose CSS custom properties for the values consumers should control, and ::part() for the elements they may restyle. Everything else is genuinely private, which is the point (Design Tokens).
  • Share styles with adoptedStyleSheets and construct the sheet once at module scope, not per instance.
  • Put a real interactive element inside — a button, an input — rather than reconstructing one. The shadow boundary does not change what semantics are worth (What Native Elements Already Do).
  • Set delegatesFocus: true when the host stands in for a single control, so host.focus() and label clicks do the obvious thing.
  • Keep the accessible name inside the boundary or on the host. aria-label on the host, or a visually-hidden label in the shadow tree, both work; an id reference across the boundary does not.
  • Expose an explicit public API — properties, methods and custom events — because the DOM inside is no longer the interface. That is a benefit, and it means the API has to exist (What a Component Owes Its Caller).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • The accessibility tree is built from the composed tree, so slotted light-DOM content is announced in its rendered position and the boundary itself is invisible to assistive technology. That part works.
  • What does not work is id references across the boundary, because they resolve within one tree. for, aria-labelledby, aria-describedby, aria-controls and aria-activedescendant all fail silently across a shadow root — no console warning, no visual symptom, just a control with no accessible name (The Rules of ARIA).
  • The workarounds, in order of preference: keep both ends of the relationship inside the same tree; put aria-label on the host; or use the element-reference IDL properties where they are supported. Duplicating the label text is not a workaround — it is a second thing to keep in sync.
  • Focus order follows the composed tree, so a slot in an unexpected position reorders tabbing in a way the light DOM markup does not show. Tab through the rendered component, not through the source (Keyboard Operability).
  • Custom elements with ElementInternals can set a role, an accessible name and ARIA states on the host itself as defaults that a consumer can still override — the correct way to give a host semantics (Semantics Before ARIA).

What can go wrong

Failure modes
  • A form control inside a shadow root does not participate in an outer <form> unless the element uses ElementInternals and formAssociated. Submissions silently omit its value (Submission: Method, Encoding and Doing It Once).
  • Focus traps built by querying document for focusable elements never find anything inside a shadow root, so a modal containing a custom element leaks focus (Focus Management).
  • End-to-end tests written against inner structure break, and test tooling differs sharply in whether and how it pierces shadow roots (End-to-End Testing).
  • A print stylesheet, a global reset, or a @media (prefers-reduced-motion) rule in the outer document does not reach inside — every shadow root needs its own (Contrast, Colour and Motion).
  • A component that re-parses its stylesheet per instance, turning a hundred widgets into a hundred parses (The Real Cost of JavaScript).
  • Server-rendered markup with no declarative shadow DOM, so the component is unstyled and unstructured until its JavaScript runs (Hydration).
Security
  • A closed shadow root is not a security boundary. Same-origin script can patch attachShadow before your element is defined and capture every root created afterwards, and it can reach nodes through events and observers regardless.
  • Nothing about a shadow root mitigates injection. innerHTML inside a shadow root is exactly as dangerous as innerHTML anywhere else (Cross-Site Scripting).
  • For real isolation of third-party content — a different origin, a separate script context, its own storage — the mechanism is an iframe, not a shadow root (Origins and the Sandbox, Third-Party Scripts and the Supply Chain).
  • CSP applies to the document as a whole. A <style> inside a shadow root is still subject to style-src, and constructing stylesheets programmatically is generally the friendlier path under a strict policy (Content Security Policy).
  • Encapsulation does cut one real class of risk: an outer page cannot restyle your component to disguise what a control does, which is a small mitigation against interface-spoofing inside a page you share (Clickjacking and Framing).
Misreads
  • "Shadow DOM is sandboxing." It is style and query encapsulation within the same origin, same script context and same event loop. An iframe is the isolation primitive.
  • "A closed shadow root hides my internals." It hides them from well-behaved code. It is a discipline mechanism, not a defence.
  • "Nothing crosses the boundary." Inherited properties, custom properties, composed events, focus and the accessibility tree all cross. Selectors and queries do not.
  • "Shadow DOM means Web Components." Custom elements, shadow DOM, templates and ES modules are four independent specifications, and each is usable without the others (Composition and Slots).
  • "Scoped CSS in my build tool is the same thing." Build-time scoping rewrites selectors and is bypassed by any sufficiently specific global rule. Shadow scoping is enforced by the engine and cannot be bypassed by specificity (Specificity).

Measuring it, and what changes in the field

How you would see this
  • The Elements panel shows #shadow-root (open|closed) inline in the tree, and the Accessibility pane resolves names against the composed tree — which is how you catch a broken id reference (A Mental Model of the Devtools).
  • The Styles pane attributes rules to the shadow root or to the host, which is the fastest way to answer "why is this inheriting that".
  • Style recalculation time in a Performance recording shows whether scoping helped, on a page with many instances (Debugging Rendering and Jank).
  • An automated accessibility check run on the rendered page catches missing accessible names on hosts; it will not catch a name that resolves to the wrong element (Accessibility Testing).
Slow device, slow network, large data, old tab
  • With many instances on a page, per-instance stylesheet parsing dominates. Shared constructed stylesheets change the cost from linear to constant.
  • On a server-rendered page, a component with no declarative shadow markup shows unstyled light DOM until its script executes — a visible flash on a slow network (Server-Side Rendering).
  • In a browser extension or an embedded widget, the outer page is genuinely hostile to your styles, and this is the case shadow DOM was designed for.
  • In an application you fully control, scoped CSS from a build tool gives most of the isolation with none of the accessibility and form-participation costs (Design Systems).
What this costs
  • You gain style isolation and lose global styling, which cuts both ways: your reset, your tokens and your prefers-reduced-motion rules all stop at the boundary and must be re-provided.
  • You gain a real API boundary and lose the ability to reach in — which is the point, and which will still be experienced as an obstacle by every colleague writing a test or an analytics selector.
  • You gain per-instance encapsulation and take on form participation, accessible naming and focus delegation as explicit work that the platform did for free outside the boundary.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALAttachment, slotting, the composed tree, style scoping, event retargeting and the fact that id references do not cross are all specified and behave the same in Blink, Gecko and WebKit. Shadow DOM is one of the better-converged parts of the platform.
  • SPEC-EVOLVINGThe pieces that fix the boundary's accessibility and ergonomics are still landing: declarative shadow DOM for server rendering, element-reference ARIA properties for cross-root relationships, and cross-root ARIA delegation proposals. Feature-detect each of these rather than assuming; the id-reference limitation is the current reality and the fallbacks in this lesson are what work today.
  • BROWSER-SPECIFICHow devtools presents the boundary differs — Chromium shows #shadow-root inline and offers a setting to show user-agent shadow roots; Firefox and Safari expose it differently and with different amounts of detail. So does test tooling: some drivers pierce shadow roots automatically, some require an explicit traversal, which makes selector strategy a per-tool decision (End-to-End Testing).

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — a shadow root is an enforced module boundary: the encapsulation argument, the "expose a small API" discipline and the testing friction are all the same argument made about private members.
  • Testing & Reliability Engineering — whether a test driver pierces shadow roots decides your entire selector strategy, and it is worth settling before the first component ships.