Shadow DOM

TL;DR / Shadow DOM creates an encapsulated DOM subtree attached to an element, isolating its internal structure and styles from the rest of the document.

How It Works

 ┌──────────────────────────────────────────────────┐
 │                         ╔══════════════════════╗ │
 │ ┌────────────┐          ║                      ║ │
 │ │ <my-card>  │────┐     ║ ┌──────────────┐     ║ │
 │ └────────────┘    │     ║ │   <style>    │     ║ │
 │                   │     ║ │  scoped CSS  │     ║ │
 │               attaches  ║ └──────────────┘     ║ │
 │  Light DOM        │     ║                      ║ │
 │  children         └────→║     Shadow Root      ║ │
 │                         ║ ┌──────────────┐     ║ │
 │                         ║ │    <slot>    │     ║ │
 │                         ║ └──────────────┘     ║ │
 │                         ║                      ║ │
 │                         ║                      ║ │
 │                         ╚══════════════════════╝ │
 └──────────────────────────────────────────────────┘

Edit diagram

Shadow DOM is one of the three pillars of the Web Components specification, alongside Custom Elements and HTML Templates. It provides browser-native DOM encapsulation — the ability to attach a hidden, isolated DOM tree to any element. Styles defined inside a shadow root do not leak out to the surrounding document, and styles from the document do not penetrate into the shadow root (with a few intentional exceptions). This is not a convention or a build-time transformation; it is enforced by the browser's rendering engine.

Attaching a Shadow Root

You create a shadow root by calling element.attachShadow({ mode: 'open' }) or { mode: 'closed' }. The mode determines whether external JavaScript can access the shadow root via element.shadowRoot. Open mode returns the shadow root reference; closed mode returns null, hiding the internal DOM from outside code. In practice, open mode is overwhelmingly more common because closed mode complicates debugging and provides only superficial encapsulation (determined scripts can still find workarounds).

Once attached, the shadow root becomes the element's rendering subtree. Any children you append to the shadow root are what the browser actually renders. The element's original children (its "light DOM") are not rendered directly — they are distributed into the shadow DOM through slots.

Style Encapsulation

This is Shadow DOM's most valuable feature. A <style> block inside a shadow root is scoped to that shadow tree. Selectors like h2 or .card-title match only elements inside the shadow root, not elements in the outer document. Conversely, document-level selectors cannot target elements inside a shadow root (with the exception of inheritable CSS properties like color and font-family, which cascade through the shadow boundary by design).

The :host selector targets the shadow host element itself from within the shadow DOM. :host(.active) matches the host only when it has the active class. :host-context(.dark-mode) matches the host when any ancestor has the dark-mode class, providing a way to respond to document-level themes without breaking encapsulation.

CSS custom properties (variables) cross the shadow boundary, which is the primary mechanism for theming shadow DOM components from the outside. The component author defines fallback values (var(--card-bg, white)) and consumers override them at a higher level. This is an intentional design that allows controlled customization without full selector access.

Slots and Light DOM Composition

Slots are the mechanism for composing external content into a shadow DOM template. A <slot> element inside the shadow root is a placeholder where the host element's children (light DOM) are rendered. Named slots (<slot name="header">) distribute light DOM children that have a matching slot="header" attribute. The default (unnamed) slot captures all light DOM children without a slot attribute.

Importantly, slotted elements remain in the light DOM for purposes of styling and JavaScript access. A ::slotted(span) selector inside the shadow root can apply limited styles to slotted elements (only compound selectors, no descendants). The light DOM owner retains full styling control over slotted content through document-level stylesheets.

Event Retargeting

Events that originate inside a shadow DOM are retargeted as they cross the shadow boundary. If a user clicks a <button> inside a shadow root, an event listener on the host element sees the event.target as the host element itself, not the internal button. The original target is only visible from within the shadow root or through event.composedPath().

Some events cross the shadow boundary (they are "composed"): click, focus, input, keydown, and most UI events. Others do not: custom events are not composed by default unless you explicitly set composed: true and bubbles: true when creating them. This distinction is critical for component communication patterns.

Declarative Shadow DOM

Traditionally, shadow roots could only be created via JavaScript, which blocked server-side rendering. Declarative Shadow DOM (supported in all modern browsers) allows shadow roots to be expressed in HTML using <template shadowrootmode="open">. The browser attaches the shadow root during HTML parsing, before any JavaScript executes. This enables SSR of web components — the shadow DOM structure arrives in the initial HTML payload, and the component hydrates later.

Gotchas

  • Global styles do not penetrate — if your design system relies on global CSS classes (Tailwind, Bootstrap), those styles will not apply inside a shadow root. You must either inject the stylesheet into each shadow root or use CSS custom properties for theming.
  • document.querySelector cannot reach inside — shadow DOM elements are invisible to document-level queries. Use element.shadowRoot.querySelector() for open-mode shadow roots.
  • Form participation is limited — form elements inside a shadow root do not participate in a parent <form> by default. The ElementInternals API and form-associated custom elements are required to bridge this gap.
  • Accessibility tree is flat — screen readers see through shadow boundaries, which is good. But ARIA relationships (aria-labelledby, aria-describedby) that reference IDs across shadow boundaries do not work because ID scoping is per-tree-scope.
  • Event retargeting breaks delegation — if you use event delegation on a parent element, event.target will be the shadow host, not the internal element that was clicked. Use event.composedPath()[0] to get the true originating element.