jsguides

How shadow DOM and web components encapsulate markup

Every <video> element you see in a browser has a hidden DOM tree inside it: buttons, progress bars, volume controls that the browser generates but that you can’t inspect or style directly. That’s shadow DOM in action. The spec gave web developers the same capability: attach a hidden DOM tree to any element, keeping its internal structure completely isolated from the rest of the page.

This isolation is what makes Web Components viable. Without it, a custom element’s internal markup and styles would be at war with every CSS selector and script on the page that used it. Shadow DOM draws a boundary.

Shadow DOM terminology

Before getting into the mechanics, here is the vocabulary:

Shadow host: the element in the regular DOM that has a shadow DOM attached to it. Shadow tree: the DOM tree living inside the shadow DOM. Shadow root: the root node of that shadow tree (the entry point). Shadow boundary: the barrier between the shadow DOM and the regular DOM tree.

Creating an isolated DOM tree

Imperatively with JavaScript

The element.attachShadow() method creates and returns a shadow root:

const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });

const span = document.createElement("span");
span.textContent = "I'm inside the shadow DOM";
shadow.appendChild(span);

Once you have the shadow root, you manipulate it like any other DOM node; appendChild, createElement, setAttribute, all work the same way.

Declaratively with HTML

If you want shadow DOM without JavaScript (useful for server-rendered pages), use the <template> element with the shadowrootmode attribute:

<div id="host">
  <template shadowrootmode="open">
    <span>I'm in the shadow DOM</span>
  </template>
</div>

When the browser parses this HTML, it replaces <template shadowrootmode="open"> with a shadow root attached to the parent element. The <template> itself disappears from the DOM; only its content remains, wrapped in a shadow root.

The shadowrootmode attribute accepts open or closed, the same as the JavaScript option.

Open mode vs closed mode

When calling attachShadow(), you pass { mode: "open" } or { mode: "closed" }.

  • Open mode: hostElement.shadowRoot returns the shadow root object. Any JavaScript running on the page can access the shadow DOM internals through this property.
  • Closed mode: hostElement.shadowRoot returns null. The shadow DOM is truly hidden.
// open
const shadow = document.querySelector("#host").attachShadow({ mode: "open" });
console.log(document.querySelector("#host").shadowRoot); // ShadowRoot object

// closed
const closedShadow = document.querySelector("#host").attachShadow({ mode: "closed" });
console.log(document.querySelector("#host").shadowRoot); // null

Closed mode is not a security mechanism; browser extensions and certain hacking techniques can still access closed shadow roots. Think of it as a signal to external code: “please don’t touch my internals.” It won’t stop a determined developer.

CSS Encapsulation

This is the main reason to use shadow DOM. Styles defined outside the shadow tree don’t match elements inside it, and styles inside the shadow tree don’t leak out:

<div id="host"></div>
<span>I'm in the regular DOM</span>

<script>
  const host = document.querySelector("#host");
  const shadow = host.attachShadow({ mode: "open" });
  shadow.innerHTML = "<span>I'm in the shadow DOM</span>";
</script>

<style>
  span { color: blue; }
</style>

The page’s span { color: blue; } styles only affect the <span> in the regular DOM. The shadow DOM span keeps whatever default styling it has. This means you can use generic selectors like span, div, or .button inside a component without worrying they’ll match or be matched by page styles.

Encapsulating styles with shadow trees

With a <style> element

A common way to add styles involves placing a <style> element directly inside the shadow tree:

const template = document.getElementById("my-component").content;
const shadow = document.querySelector("#host").attachShadow({ mode: "open" });
shadow.appendChild(template.cloneNode(true));

The JavaScript side pulls the template’s content from the page and clones it into the shadow root. The HTML below defines that template. Any styles inside it are scoped to the shadow tree, so button selectors here won’t leak into the page and page styles won’t override them. The cloneNode(true) call is important: without it, the same template node would be moved rather than copied, and a second use of the component would find an empty template.

<template id="my-component">
  <style>
    button { background: #007bff; color: white; border: none; padding: 8px 16px; }
    button:hover { background: #0056b3; }
  </style>
  <button>Click me</button>
</template>

The <style>-in-template approach is straightforward but creates duplicate stylesheets — every component instantiation gets its own parsed copy. When a page uses dozens of instances of the same component, that adds up in memory and parse time.

With constructable stylesheets

Modern browsers support CSSStyleSheet objects that you can create and share across multiple shadow roots:

const sheet = new CSSStyleSheet();
sheet.replaceSync("button { background: #007bff; color: white; }");

const shadow = document.querySelector("#host").attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [sheet];

The advantage over <style> elements: you create the stylesheet once and assign it to many shadow roots. The browser parses it once. You can also mutate it dynamically and the changes propagate everywhere it’s used. This is particularly useful when theming or responding to user preferences — change the sheet once and every component that adopted it updates immediately, without touching any DOM nodes.

sheet.replace("button { background: red; }"); // updates all components using this sheet

Shadow DOM and custom elements

Custom elements almost always attach their own shadow DOM. Without it, the element’s internal implementation would be visible and manipulable by the page, making it fragile and impossible to maintain. Every custom element tutorial eventually reaches this conclusion: shadow DOM is what turns a custom element from a styled wrapper into a self-contained widget that the page can’t accidentally break.

class FilledCircle extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });

    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("width", "100");
    svg.setAttribute("height", "100");

    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    circle.setAttribute("cx", "50");
    circle.setAttribute("cy", "50");
    circle.setAttribute("r", "50");
    circle.setAttribute("fill", this.getAttribute("color") ?? "blue");

    svg.appendChild(circle);
    shadow.appendChild(svg);
  }
}

customElements.define("filled-circle", FilledCircle);

The FilledCircle class reads the color attribute from the host element and draws an SVG circle with that fill color. Because the SVG lives inside a shadow root, external CSS can’t accidentally restyle the circle, and external JavaScript can’t reach in to mess with the cx, cy, or r attributes. The component exposes a single public API: the color attribute on the custom element tag.

<filled-circle color="green"></filled-circle>
<filled-circle color="#ff6600"></filled-circle>

The custom element developer controls the internal structure completely. Page CSS can’t reach in and break the SVG layout. Page JS can’t manipulate the circles directly. The component owns its internals.

Slots and light DOM content

Shadow DOM can expose <slot> elements that accept content from outside the shadow tree; this is how web components compose with the page.

class MyCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <div class="card">
        <h2><slot name="title">Default Title</slot></h2>
        <p><slot>Default content</slot></p>
      </div>
      <style>
        .card { border: 1px solid #ccc; padding: 16px; border-radius: 8px; }
        h2 { margin: 0 0 8px; }
      </style>
    `;
  }
}
customElements.define("my-card", MyCard);

The MyCard component defines two slots: a named slot for the title and a default slot for the body content. The shadow.innerHTML assignment also includes scoped styles that apply only within this component. Because the styles live inside the shadow tree, the .card and h2 selectors won’t clash with any CSS on the host page. A page author only needs to learn two things to use this component: the slot="title" attribute and putting regular HTML inside the tag.

<my-card>
  <span slot="title">Hello World</span>
  <p>This content fills the default slot.</p>
</my-card>

The <slot name="title"> receives the element with slot="title" from outside. The unnamed <slot> receives everything else. Slots connect the shadow tree to the light DOM without breaking encapsulation — the component author decides where external content lands, but external code never sees or touches the internals around those landing zones.

Styling slotted content

Use ::slotted() to style elements that have been assigned to a slot, from inside the shadow DOM:

::slotted(h2) { font-size: 1.25rem; color: #333; }
::slotted(p) { color: #666; }

Note that ::slotted() has limited pseudo-selectors available; you can only use element-type and class selectors inside it, not descendant or combinator selectors. The restriction exists because slotted content still belongs to the light DOM — the shadow tree borrows it visually but doesn’t fully own it.

Host-specific styles

The :host pseudo-class selects the shadow host element from inside the shadow tree. While ::slotted() styles the content that enters a slot, :host styles the wrapper element itself — the custom element tag that contains the shadow root.

:host { display: block; }
:host([hidden]) { display: none; }
:host(.fancy) { border: 2px solid gold; }

:host() lets you conditionalize on the host element’s attributes. :host-context() lets you conditionalize on an ancestor of the host.

Attribute Inheritance

Shadow trees and <slot> elements inherit dir (text direction) and lang attributes from their shadow host. This ensures text direction and language styling flows into the shadow DOM correctly.

Decide what stays public

The easiest components to maintain are the ones with a narrow, intentional surface area. Let attributes, slots, and host classes carry the pieces of state that outside code truly needs. Keep everything else internal. That approach gives you room to change the markup later without forcing users of the component to rewrite their pages. It also makes component docs much easier to write because the public contract is small and visible.

Prefer clear extension points

If a page needs to style or configure a component, give it a direct hook rather than asking it to reach into the shadow tree. A named slot, a host attribute, or a documented custom property is much easier to support than fragile selector tricks. The more explicit the hook, the less time you spend debugging accidental coupling between the component and the page that hosts it.

Key Takeaways

  • attachShadow({ mode: "open" }) creates an accessible shadow root; mode: "closed" hides it
  • CSS selectors in the page don’t reach into shadow DOM, and shadow styles don’t leak out
  • Use <template> + shadowrootmode for declarative shadow DOM creation
  • Use adoptedStyleSheets with CSSStyleSheet for shareable, mutable styles
  • Custom elements almost always use shadow DOM; it’s what makes them reliable
  • Slots let you compose external content into a component’s shadow tree

Design the public surface first

Shadow DOM works best when you decide what the outside world should be allowed to control before you write the internals. Slots, attributes, and host classes all become part of that public surface. If you define them early, the component stays easier to use and the internal structure can change later without breaking callers.

Keep styling responsibilities clear

A shadow tree should own its internal styles, while the page should own its layout around the component. That separation keeps the component predictable. When you need a page-level override, expose a deliberate hook such as a host class or attribute instead of letting outside CSS reach into the internals by accident.

See Also