The Role of Shadow DOM in Web Components

Web Components are often discussed as a single concept, but they are actually three distinct platform APIs: Custom Elements, HTML Templates, and Shadow DOM. Each can be used independently. A Custom Element works fine without Shadow DOM or templates, but combining the three unlocks stronger isolation, reusability, and security. The real power of Shadow DOM comes from its ability to encapsulate related markup and styles inside a DocumentFragment, drawing clear lines between different parts of an application.

That encapsulation is the core reason Shadow DOM exists. In the traditional light DOM, styles and scripts from one library can easily collide with another. Without encapsulation, developers must manually ensure unique IDs and write increasingly specific CSS rules to avoid conflicts. That leads to verbose code, slower load times, and fragile maintainability. Shadow DOM avoids this by giving each component its own boundary. Native elements like <video> and <details> already rely on internal shadow roots to keep their inner workings safe from global styles and scripts. Using Shadow DOM in custom components gives developers access to that same hidden power that ships with the browser.

<!-- div soup -->
<div id="my-custom-app-framework-landingpage-header" class="my-custom-app-framework-foo">
  <div><div><div><div><div><div>etc...</div></div></div></div></div></div>
</div>

Supported Hosts and Instantiation

Shadow roots are most commonly attached to Custom Elements, but they also work on many standard elements, including <aside>, <blockquote>, <body>, <div>, <footer>, <h1> through <h6>, <header>, <main>, <nav>, <p>, <section>, and <span>. Each host element accepts only one shadow root. Certain elements, such as <input> and <select>, already contain internal shadow roots that script cannot touch. You can inspect those by enabling Show User Agent Shadow DOM in Developer Tools, though the setting is off by default.

User Agent DOM Setting in Chrome Developer Tools
Show User Agent DOM Setting in Chrome Developer Tools. (Large preview)

Attaching a shadow root can be done imperatively or declaratively. The imperative route uses attachShadow({ mode }). The mode property accepts either open—which exposes the root through element.shadowRoot—or closed, which hides it from external scripts.

const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Hello from the Shadow DOM!</p>';
document.body.appendChild(host);

With an open shadow root, the content is reachable and queryable like any other DOM node from outside:

host.shadowRoot.querySelector('p'); // selects the paragraph element

Choosing closed makes the element's shadowRoot property return null. The internal structure remains accessible only through the shadow reference retained in the creating scope. This mode is a genuine security feature. For a component that displays sensitive data, such as a banking widget with an account number, an open root allows any script on the page to parse the contents. In closed mode, that data is shielded from external scripts; only the user can manually copy it or inspect the element. A closed-first approach is worth adopting as a default habit, reserving open mode for debugging or specific, unavoidable limitations.

shadow.querySelector('p');

Declarative instantiation works without a single line of JavaScript. Placing a <template> with a shadowrootmode attribute inside a supported element makes the browser upgrade that element with a shadow root automatically—even when scripting is disabled.

<my-widget>
  <template shadowrootmode="closed">
    <p> Declarative Shadow DOM content </p>
  </template>
</my-widget>

The declarative method also supports both open and closed modes. closed content is unreachable from scripts in the declarative case, with one exception: if the shadow root is attached to a registered Custom Element, ElementInternals provides access to that automatically attached root.

class MyWidget extends HTMLElement {
  #internals;
  #shadowRoot;
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#shadowRoot = this.#internals.shadowRoot;
  }
  connectedCallback() {
    const p = this.#shadowRoot.querySelector('p')
    console.log(p.textContent); // this works
  }
};
customElements.define('my-widget', MyWidget);
export { MyWidget };

Beyond the Mode Option

Element.attachShadow() accepts three additional configuration options besides mode, each addressing a specific encapsulation edge case.

clonable: true changes how cloning behaves. Previously, cloning a host element with Node.cloneNode(true) or document.importNode(node, true) produced only a shallow copy—an empty <div> without the shadow root's contents. That was never a problem for Custom Elements that construct their own shadow roots internally, but it meant each declarative shadow root needed its own template and could not be shared. This option enables selective cloning of components when reuse is actually desired.

<div id="original">
  <template shadowrootmode="closed" shadowrootclonable>
    <p> This is a test  </p>
  </template>
</div>

<script>
  const original = document.getElementById('original');
  const copy = original.cloneNode(true); copy.id = 'copy';
  document.body.append(copy); // includes the shadow root content
</script>

serializable: true allows a string representation of the shadow root content to be captured. Calling Element.getHTML() on the host returns a template copy reflecting the shadow DOM's current state, including nested shadowrootserializable instances. That output can be injected into another host or cached for later. One caution: in Chrome, this operation can work even through a closed shadow root, so it is possible to leak user data unintentionally. A safer design is a closed wrapper around open internal content.

<wrapper-element></wrapper-element>

<script>
  class WrapperElement extends HTMLElement {
    #shadow;
    constructor() {
      super();
      this.#shadow = this.attachShadow({ mode:'closed' });
      this.#shadow.setHTMLUnsafe(`
        <nested-element>
          <template shadowrootmode="open" shadowrootserializable>
            <div id="test">
              <template shadowrootmode="open" shadowrootserializable>
                <p> Deep Shadow DOM Content </p>
              </template>
            </div>
          </template>
        </nested-element>
      `);
      this.cloneContent();
    }
    cloneContent() {
      const nested = this.#shadow.querySelector('nested-element');
      const snapshot = nested.getHTML({ serializableShadowRoots: true });
      const temp = document.createElement('div');
      temp.setHTMLUnsafe(`<another-element>${snapshot}</another-element>`);
      const copy = temp.querySelector('another-element');
      copy.shadowRoot.querySelector('#test').shadowRoot.querySelector('p').textContent = 'Changed Content!';
      this.#shadow.append(copy);
    }
  }
  customElements.define('wrapper-element', WrapperElement);
  const wrapper = document.querySelector('wrapper-element');
  const test = wrapper.getHTML({ serializableShadowRoots: true });
  console.log(test); // empty string due to closed shadow root
</script>

Injecting that serialized output properly requires setHTMLUnsafe(). Regular innerHTML assignment will not trigger automatic shadow root initialization because the content contains <template> elements. This method should only be used with fully trusted content.

delegatesFocus: true makes the host act as a label for its internal focused element. Clicking anywhere on the host, or calling .focus() on it, moves the cursor to the first focusable element inside the shadow root. The :focus pseudo-class is applied to the host as well. This matters for form-participating components, but it only handles focus delegation. Form submissions are not wired through the shadow boundary automatically—an input's value will not appear in a form submission, and validation states are not communicated outward. Similar connectivity gaps affect ARIA and accessibility. These integration issues are addressed through ElementInternals, which is a separate topic, and they are also a reason to question when a light DOM form is the more reliable choice.

<custom-input>
  <template shadowrootmode="closed" shadowrootdelegatesfocus>
    <fieldset>
      <legend> Custom Input </legend>
      <p> Click anywhere on this element to focus the input </p>
      <input type="text" placeholder="Enter some text...">
    </fieldset>
  </template>
</custom-input>

Slots and the Light DOM Boundary

Slots are the mechanism for injecting content into a component's internal structure while keeping it in the light DOM. Each shadow root can contain one unnamed default <slot>; every other slot must be named. Named slots let users provide content for specific locations inside the component and also support fallback content for slots that are omitted.

<my-widget>
  <template shadowrootmode="closed">
    <h2><slot name="title"><span>Fallback Title</span></slot></h2>
    <slot name="description"><p>A placeholder description.</p></slot>
    <ol><slot></slot></ol>
  </template>
  <span slot="title"> A Slotted Title</span>
  <p slot="description">An example of using slots to fill parts of a component.</p>
  <li>Foo</li>
  <li>Bar</li>
  <li>Baz</li>
</my-widget>

Default slots also accept fallback capability, but they will be filled by stray text nodes. In practice, that means all whitespace in the host element's markup must be collapsed for a default slot to work as intended.

<my-widget><template shadowrootmode="closed">
  <slot><span>Fallback Content</span></slot>
</template></my-widget>

Slot elements fire slotchange events when their assignedNodes() change. The event doesn't carry a reference to the slot or the nodes themselves, so the handler must receive those directly.

class SlottedWidget extends HTMLElement {
  #internals;
  #shadow;
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#shadow = this.#internals.shadowRoot;
    this.configureSlots();
  }
  configureSlots() {
    const slots = this.#shadow.querySelectorAll('slot');
    console.log({ slots });
    slots.forEach(slot => {
      slot.addEventListener('slotchange', () => {
        console.log({
          changedSlot: slot.name || 'default',
          assignedNodes: slot.assignedNodes()
        });
      });
    });
  }
}
customElements.define('slotted-widget', SlottedWidget);

Multiple elements can share one slot, whether assigned through the slot attribute in markup or programmatically.

const widget = document.querySelector('slotted-widget');
const added = document.createElement('p');
added.textContent = 'A secondary paragraph added using a named slot.';
added.slot = 'description';
widget.append(added);

Slotted content remains part of the document tree. In the example above, the paragraph is appended to the host element, so it can be queried from document—unlike shadow root content, which is invisible to outside queries. Inside a class definition, this.children or this.querySelector reaches that slotted content. The Shadow DOM itself only exposes the <slot> elements, not the nodes assigned to them.

const widgetTitle = document.querySelector('my-widget [slot=title]');
widgetTitle.textContent = 'A Different Title';

What Encapsulation Leaves Open

Understanding when and how to apply Shadow DOM moves you from guessing about encapsulation to using it deliberately. The markup and scripting side is now covered. The remaining major piece—style encapsulation inside the shadow boundary—is where the next set of decisions and trade-offs lives.

Smashing Editorial