Blog

The Shadow DOM: A Quick Explanation

Key Takeaways

  • Shadow DOM gives a DOM subtree its own selector scope, reducing accidental CSS interactions between a component and its surrounding page.
  • attachShadow({ mode: 'open' }) creates a shadow root whose content can render inside the host without becoming its light-DOM children.
  • Slots, CSS custom properties, and exposed parts provide intentional ways to compose and style a component.
  • Shadow DOM is optional for Web Components. It isn't JavaScript isolation, a security boundary, or a guaranteed performance optimization.

Shadow DOM is a browser feature that attaches a separate DOM subtree to an element and scopes ordinary CSS selectors to that tree. It helps you build reusable UI with fewer accidental interactions with the surrounding document. Think of it as an encapsulation boundary with explicit ways through—not a sandbox.

Shadow Hosts, Shadow Roots, and Light DOM

The terminology describes where nodes belong:

  • Shadow host: the element to which you attach a shadow root.
  • Shadow root: the root node of the attached subtree.
  • Shadow tree: the nodes inside that shadow root.
  • Light DOM: the host's ordinary children, rather than nodes inside its shadow tree.

These are structural boundaries, not separate JavaScript environments. A document-level selector doesn't automatically search inside a shadow tree, but code with a reference to the root can query and modify its contents.

Native <video> and <audio> controls offer a useful analogy: a simple element can expose more elaborate browser-provided UI. Their implementation isn't a portable author-facing shadow tree to depend on, so an author-created root is a better starting example.

Create a Shadow Root with attachShadow()

The core attachShadow() API is broadly available across browsers; that doesn't imply equal support for every newer option. The method attaches a shadow root to an eligible element and returns that root.

Start with an empty host, create its root, then append a paragraph. You can save this example as an HTML file and open it in a browser:

<div id="myDiv"></div>

<script type="module">
  const host = document.querySelector('#myDiv');
  const shadow = host.attachShadow({ mode: 'open' });

  const p = document.createElement('p');
  p.textContent = 'hello world';
  shadow.appendChild(p);
</script>

A div is a valid shadow host; you don't need a custom element just to attach a root. The DOM Standard explicitly includes div among the permitted built-in host names.

The paragraph renders visibly inside the host. It isn't a light-DOM child: document.querySelector('#myDiv p') returns null, while host.shadowRoot.querySelector('p') finds it. An empty light-DOM child list does not mean an empty visual result.

Open Versus Closed Shadow Roots

  • Open: host.shadowRoot returns the attached root.
  • Closed: host.shadowRoot returns null, although code can retain the root returned by attachShadow().

The mode controls this access path, not whether the content renders. Closed mode doesn't create a secure execution environment or make your component's data confidential.

Use Shadow DOM in a Web Component

For reusable UI, put the setup inside a custom element. This standalone example registers <hello-card>, creates internal styles and a paragraph, and adds a default slot for consumer-provided content. The Custom Elements API supplies the registration mechanism.

<hello-card>
  <strong>Content supplied by the page</strong>
</hello-card>

<script type="module">
  class HelloCard extends HTMLElement {
    constructor() {
      super();
      const shadow = this.attachShadow({ mode: 'open' });

      const style = document.createElement('style');
      style.textContent = `
        :host {
          display: block;
          padding: 1rem;
        }
        p {
          color: var(--hello-color, teal);
        }
        ::slotted(strong) {
          font-style: italic;
        }
      `;

      const p = document.createElement('p');
      p.textContent = 'hello world';
      p.setAttribute('part', 'message');

      const slot = document.createElement('slot');
      shadow.append(style, p, slot);
    }
  }

  customElements.define('hello-card', HelloCard);
</script>

Here, this is the host. Its constructor creates the shadow root and internal nodes; customElements.define('hello-card', HelloCard) associates the element name with the class. The paragraph and slot belong to the shadow tree, while the page's strong element remains in light DOM.

How Slots Combine Light DOM and Shadow DOM

A <slot> is a placeholder that displays assigned light-DOM content within the component's rendered layout. Assignment doesn't move that content into the shadow tree. A default slot handles children without a slot name; named slots match a child's slot attribute to a slot's name.

hello-card                         <-- shadow host
+-- strong                         <-- light-DOM child
|   "Content supplied by the page"
|
+-- #shadow-root (open)
    +-- style
    +-- p [part="message"]
    |   "hello world"
    +-- slot                       <-- displays the strong element

Because the supplied element remains in light DOM, the page can still select and style it. This distinction matters: projecting content into a component doesn't give it the same styling boundaries as internally created markup.

Shadow DOM CSS Styling: What Crosses the Boundary?

An ordinary page selector such as hello-card p doesn't reach the internal paragraph. Conversely, an ordinary p rule in the shadow stylesheet doesn't select paragraphs elsewhere in the document. The boundary has deliberate styling hooks:

MechanismWhere you use itWhat it targets or supplies
Internal selectorsInside the shadow stylesheetElements in that shadow tree.
:hostInside the shadow stylesheetThe host element itself.
::slotted()Inside the shadow stylesheetAssigned light-DOM elements matching its argument—not their descendants or bare text nodes.
::part()In the surrounding page's stylesheetInternal elements explicitly exposed with a matching part name.
CSS custom propertiesOften set on the host and consumed internallyTheme values the component chooses to use.

These mechanisms have different directions: ::slotted() styles supplied content from inside, while ::part() styles exposed internals from outside. Neither is a general-purpose selector that pierces every boundary.

For the component above, add this CSS to the page's stylesheet:

/* Page stylesheet for the HelloCard example. */
hello-card {
  --hello-color: rebeccapurple;
}

hello-card::part(message) {
  font-weight: 700;
}

The custom property supplies the paragraph's color, and the exposed part lets the page change its font weight without depending on its tag name. Ordinary inherited properties, such as text color and font settings, can also flow through the host into shadow content; selector scoping isn't a reset of inheritance.

Unregistered CSS custom properties inherit by default. Document your supported property and part names as public styling hooks rather than asking consumers to modify internal markup.

Events Across Shadow Boundaries

Events have boundary rules too. For an event dispatched on an internal element, composed: true allows it to cross the shadow boundary, while bubbles: true enables bubbling to ancestor listeners. A custom event isn't composed by default.

Run this in the browser console after the HelloCard example has loaded:

const card = document.querySelector('hello-card');

card.addEventListener('message-ready', (event) => {
  console.log(event.target === card, event.detail.text);
});

card.shadowRoot.querySelector('p').dispatchEvent(
  new CustomEvent('message-ready', {
    detail: { text: 'hello world' },
    bubbles: true,
    composed: true,
  }),
);

The listener receives true and hello world. Although the paragraph dispatched the event, the outside listener sees the host as event.target. This is retargeting: the event presents a target appropriate to the listener's side of the boundary.

For a component's public event API, prefer a meaningful event name and documented detail data over requiring consumers to discover internal elements.

When Should You Use Shadow DOM?

Use it when a reusable component needs ownership of its internal markup and styling. Consider light DOM when the component should participate directly in the page's existing selectors and styling conventions. Custom elements don't have to attach a shadow root.

Framework component boundaries aren't automatically native shadow boundaries. Angular offers opt-in native encapsulation through ViewEncapsulation.ShadowDom; its default is ViewEncapsulation.Emulated. React's ordinary rendering into a supplied DOM container doesn't automatically create shadow roots.

Keep those concepts separate when comparing Shadow DOM and virtual DOM.

Choose Shadow DOM for encapsulation, not an assumed speedup. It doesn't prevent layout thrashing—repeatedly forcing layout by interleaving layout-dependent reads and writes. Profile the actual workload; the browser's layout-thrashing guidance and our JavaScript performance tips address that separate concern.

Common Mistakes

  • Treating closed mode as security. Design for encapsulation, not confidentiality.
  • Expecting global CSS to style internal markup. Publish custom properties and parts for the customization you intend to support.
  • Assuming slotted content becomes internal content. Keep its light-DOM ownership in mind when designing styles and queries.

FAQ

Is Shadow DOM part of all Web Components?

No. A custom element can render using ordinary light DOM without attaching a shadow root. Shadow DOM is useful when you need a separate subtree and native CSS selector scoping.

What is the difference between light DOM and Shadow DOM?

Light DOM consists of an element's ordinary children; Shadow DOM is a separate subtree attached through a shadow root. Slots can display light-DOM content within the shadow layout without moving those nodes into the shadow tree.

Does Shadow DOM isolate JavaScript or provide security?

No. Shadow DOM doesn't create a separate JavaScript environment or security boundary. Open roots are accessible through the host's shadowRoot property, and closed mode isn't a confidentiality mechanism.

How can you style content inside a Shadow DOM?

Use internal styles for shadow-tree elements and :host for the host. Consumers can use exposed ::part() hooks and supported CSS custom properties; ::slotted() lets internal styles target assigned light-DOM elements.

Does Shadow DOM improve performance?

Not automatically. Use it for encapsulation and composition, then profile rendering and interaction performance in your application; a shadow root doesn't prevent forced layout or layout thrashing.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me