W3docs

JavaScript Shadow DOM

The Shadow DOM lets you attach a hidden, encapsulated DOM tree to an element so its markup, styles, and scripts stay isolated from the rest of the page.

The Shadow DOM is a core building block of Web Components, enabling you to attach an encapsulated DOM tree and isolated style scope to an element. This guide covers what the Shadow DOM is, why it matters, how to create open and closed shadow roots, scope styles, project content with slots, and wire everything together inside a reusable custom element.

What is Shadow DOM?

Shadow DOM lets you attach a separate, hidden DOM subtree to an element. Markup and styles inside that subtree are encapsulated: they do not leak out, and global styles do not leak in. This solves one of the oldest problems in front-end development — global CSS and IDs colliding across components.

A few terms are worth defining up front:

  • Shadow host — the regular element the shadow tree is attached to.
  • Shadow root — the root node of the hidden tree, returned by attachShadow().
  • Shadow tree — the DOM inside the shadow root.
  • Light DOM — the element's ordinary children, written in normal markup; these can be projected into the shadow tree through slots.

The browser itself uses shadow DOM internally: the controls of a <video> or <input type="range"> element live in a shadow tree you cannot reach, which is exactly why their internals never collide with your CSS.

In the example below, two elements share the class shadow-box, yet each keeps its own styling because one lives in the main document and the other inside a shadow root.

<head>
  <style>
    .shadow-box {
      padding: 10px;
      border: 1px solid #000;
      background-color: lightcoral;
      color: white;
    }
  </style>
</head>
<body>
  <div class="shadow-box">This is styled by the main document</div>
  <div id="host"></div>
  <script>
    // Create a shadow root
    const hostElement = document.getElementById('host');
    const shadowRoot = hostElement.attachShadow({ mode: 'open' });

    // Attach shadow DOM content
    shadowRoot.innerHTML = `
      <style>
        .shadow-box {
          padding: 10px;
          border: 1px solid #000;
          background-color: lightblue;
          color: black;
        }
      </style>
      <div class="shadow-box">Hello, Shadow DOM!</div>
    `;
  </script>
</body>

In this example, there are two elements with the same class name shadow-box. The first element is styled by the main document's CSS, while the second element is styled by the Shadow DOM's CSS. As you can see, the styles defined in the Shadow DOM do not affect the elements in the main document and vice versa. This demonstrates the encapsulation provided by the Shadow DOM, allowing you to create isolated and reusable components without worrying about style conflicts.

Creating a Shadow Root

To create a shadow root, use the attachShadow method on an element. The shadow root can be either open or closed. An open shadow root is accessible from JavaScript outside the shadow tree, while a closed shadow root is not.

Open Shadow Root

An open shadow root allows access and manipulation from external JavaScript. In the example below, we manipulate the text content inside the shadow root after the shadow root is created.

<body>
  <div id="open-shadow-host"></div>
  <button id="open-shadow-btn">Change Shadow Content</button>

  <script>
    const openShadowHost = document.getElementById('open-shadow-host');
    const openShadowRoot = openShadowHost.attachShadow({ mode: 'open' });

    openShadowRoot.innerHTML = `
      <style>
        .shadow-content {
          color: blue;
          padding: 10px;
          border: 1px solid black;
        }
      </style>
      <div class="shadow-content">This is an open shadow root</div>
    `;

    document.getElementById('open-shadow-btn').addEventListener('click', () => {
      openShadowRoot.querySelector('.shadow-content').textContent = 'Open Shadow Root content updated!';
    });
  </script>
</body>

In this example, a button is provided to change the content of the shadow DOM. Since the shadow root is open, we can access and manipulate its content from the main document.

Closed Shadow Root

A closed shadow root restricts access from external scripts, providing better encapsulation. In the example below, we try to manipulate the text content inside the shadow root after the shadow root is created, but it is not possible since it is closed.

<body>
  <div id="closed-shadow-host"></div>
  <button id="closed-shadow-btn">Try to Change Shadow Content</button>

  <script>
    const closedShadowHost = document.getElementById('closed-shadow-host');
    const closedShadowRoot = closedShadowHost.attachShadow({ mode: 'closed' });

    closedShadowRoot.innerHTML = `
      <style>
        .shadow-content {
          color: red;
          padding: 10px;
          border: 1px solid black;
        }
      </style>
      <div class="shadow-content">This is a closed shadow root</div>
    `;

    // closedShadowHost.shadowRoot is null for closed roots, so this throws a TypeError
    document.getElementById('closed-shadow-btn').addEventListener('click', () => {
      try {
        closedShadowHost.shadowRoot.querySelector('.shadow-content').textContent = 'Attempted to update closed shadow root!';
      } catch (e) {
        alert('Cannot access shadow root content from outside!');
      }
    });
  </script>
</body>

Here the attempt fails because the shadow root is closed: closedShadowHost.shadowRoot returns null, so null.querySelector(...) throws a TypeError and the catch block runs. The reference returned by attachShadow({ mode: 'closed' }) is the only way to reach that tree, so keep it private inside your component.

A common misconception is that closed makes a component truly secure — it does not. It only discourages casual external access; code that holds the original root reference (or patches attachShadow) can still reach in. Use open unless you have a concrete reason to hide internals, because open makes debugging and testing far easier.

Aspectmode: 'open'mode: 'closed'
host.shadowRootReturns the shadow rootReturns null
External accessAllowed via host.shadowRootOnly via the saved reference
Typical useMost components, easy debuggingHiding internals from page scripts
DevTools inspectionFully visibleVisible, but harder to script against

Styling within Shadow DOM

Warning

When implementing JavaScript Shadow DOM, ensure proper encapsulation to prevent unintended styling or scripting conflicts.

Styles defined within a shadow root do not affect elements outside it, and vice versa. This encapsulation is beneficial for creating reusable components.

<head>
  <style>
    .styled-box {
      color: red;
      background-color: yellow;
      padding: 10px;
      border: 1px solid green;
    }
  </style>
</head>
<body>
  <div class="styled-box">This is styled by the main document</div>
  <div id="styled-host"></div>

  <script>
    const styledHost = document.getElementById('styled-host');
    const shadowRoot = styledHost.attachShadow({ mode: 'open' });

    shadowRoot.innerHTML = `
      <style>
        .styled-box {
          color: white;
          background-color: black;
          padding: 10px;
          border-radius: 5px;
        }
      </style>
      <div class="styled-box">Styled by Shadow DOM</div>
    `;
  </script>
</body>

In this example, there are two elements with the class name styled-box. The first element is styled by the main document's CSS, while the second element is styled by the Shadow DOM's CSS. The styles defined in the Shadow DOM do not affect the elements in the main document, and the styles defined in the main document do not affect the elements in the Shadow DOM. This demonstrates how Shadow DOM encapsulates styles, ensuring no conflicts between the component's styles and the global styles.

Special Selectors for Shadow DOM

Encapsulation does not mean total isolation. Three selectors give you controlled hooks across the boundary:

  • :host — used inside the shadow tree to style the host element itself. :host(.active) matches only when the host carries that class.
  • ::slotted(selector) — used inside the shadow tree to style light-DOM nodes projected into a slot. It can only target the top-level slotted elements, not their descendants.
  • ::part(name) — used in the outer document to style an internal element the component explicitly exposes with a part="name" attribute. This is the sanctioned way to let consumers theme a component without reaching into its internals.
<body>
  <div id="theme-host">
    <span>Projected from the light DOM</span>
  </div>

  <style>
    /* Outer page can only reach parts the component exposes */
    #theme-host::part(label) {
      text-decoration: underline;
    }
  </style>

  <script>
    const host = document.getElementById('theme-host');
    const root = host.attachShadow({ mode: 'open' });

    root.innerHTML = `
      <style>
        :host { display: block; padding: 10px; border: 2px solid teal; }
        .label { font-weight: bold; color: teal; }
        ::slotted(span) { color: crimson; }
      </style>
      <div class="label" part="label">Styled with :host and ::part</div>
      <slot></slot>
    `;
  </script>
</body>

The :host rule borders the whole component, .label is internal and private, ::slotted(span) colors the projected light-DOM text, and ::part(label) lets the outer page underline the label it was given permission to theme. Anything not exposed as a part stays untouchable from outside.

Slotting: Light DOM Content in Shadow DOM

Slots allow developers to pass light DOM (regular DOM) content into a shadow DOM, making the shadow DOM more flexible and reusable.

<div id="slot-host">
  <span slot="title">Shadow DOM Slot Example</span>
</div>

<script>
  const slotHost = document.getElementById('slot-host');
  const shadowRoot = slotHost.attachShadow({ mode: 'open' });

  shadowRoot.innerHTML = `
    <style>
      .container {
        border: 1px solid #ccc;
        padding: 10px;
      }
    </style>
    <div class="container">
      <h1><slot name="title"></slot></h1>
      <p>This is a Shadow DOM component with a slot for the title.</p>
    </div>
  `;
</script>

In this example, the <slot> element is used to pass content from the light DOM into the shadow DOM. The slot attribute on the span element matches the name attribute of the slot element in the shadow DOM, allowing the span content to be projected into the shadow DOM.

JavaScript Interaction with Shadow DOM

Interacting with the Shadow DOM via JavaScript requires understanding the encapsulation boundaries. Direct manipulation within the shadow root is straightforward, but external interaction requires careful handling.

Accessing Shadow DOM Elements

To access elements within a shadow DOM, use the shadowRoot property.

<div id="interactive-host"></div>

<script>
  const interactiveHost = document.getElementById('interactive-host');
  const shadowRoot = interactiveHost.attachShadow({ mode: 'open' });

  shadowRoot.innerHTML = `
    <button id="shadow-btn">Click me</button>
  `;

  const shadowButton = shadowRoot.querySelector('#shadow-btn');
  shadowButton.addEventListener('click', () => {
    alert('Button inside Shadow DOM clicked!');
  });
</script>

In this example, we access the button inside the shadow DOM using querySelector on the shadow root. Because the shadow root is open, we can attach event listeners and manipulate elements directly from the main document.

Event Retargeting

Events that bubble out of a shadow tree are retargeted: to listeners in the outer document, event.target points at the shadow host, not the inner element that was actually clicked. This keeps internal structure private. Inside the shadow tree, the real target is still available through event.composedPath()[0] or event.target.

<div id="event-host"></div>

<script>
  const host = document.getElementById('event-host');
  const root = host.attachShadow({ mode: 'open' });
  root.innerHTML = '<button id="inner">Click me</button>';

  // Listener in the OUTER document
  document.addEventListener('click', (e) => {
    console.log('Outer target:', e.target.id || e.target.tagName);
    console.log('Real target:', e.composedPath()[0].id);
  });
</script>

Clicking the button logs Outer target: event-host (retargeted to the host) but Real target: inner from composedPath(). Note that custom events only cross the shadow boundary when created with { bubbles: true, composed: true }.

Shadow DOM Practical Examples

Creating a Reusable Web Component

Creating a reusable web component using Shadow DOM involves defining a custom element and attaching a shadow root to it.

<body>
  <custom-card title="Hello World"></custom-card>

  <script>
    class CustomCard extends HTMLElement {
      constructor() {
        super();
        const shadowRoot = this.attachShadow({ mode: 'open' });
        shadowRoot.innerHTML = `
          <style>
            .card {
              padding: 10px;
              border: 1px solid #ddd;
              border-radius: 5px;
              box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            }
            .card-title {
              font-size: 1.2em;
              margin-bottom: 5px;
            }
          </style>
          <div class="card">
            <div class="card-title">${this.getAttribute('title')}</div>
            <div class="card-content"><slot></slot></div>
          </div>
        `;
      }
    }

    customElements.define('custom-card', CustomCard);
  </script>
</body>

In this example, a custom element <custom-card> is created with a shadow DOM. The shadow DOM encapsulates the styles and structure of the component, making it reusable without worrying about style conflicts with the main document. Pairing Shadow DOM with custom elements and the <template> element is the standard recipe for production Web Components.

Integrating with Frameworks

Shadow DOM can be used seamlessly with modern JavaScript frameworks like React, Angular, and Vue.

React Example

In React, you can attach a shadow DOM to a container element as follows:

<body>
  <div id="root"></div>
  <!-- React and ReactDOM CDN links -->
  <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  <script type="text/babel">
    const { useRef, useLayoutEffect } = React;

    const CustomCard = ({ title, content }) => {
      const cardRef = useRef(null);

      useLayoutEffect(() => {
        if (cardRef.current) {
          const shadowRoot = cardRef.current.attachShadow({ mode: 'open' });

          shadowRoot.innerHTML = `
            <style>
              .card {
                padding: 10px;
                border: 1px solid #ddd;
                border-radius: 5px;
                box-shadow: 0 2px 5px rgba(0,0,0,0.2);
              }
              .card-title {
                font-size: 1.2em;
                margin-bottom: 5px;
              }
            </style>
            <div class="card">
              <div class="card-title">${title}</div>
              <div class="card-content">${content}</div>
            </div>
          `;
        }
      }, [title, content]);

      return <div ref={cardRef}></div>;
    };

    const App = () => (
      <CustomCard title="Hello World" content="This is content inside the shadow DOM.">
      </CustomCard>
    );

    const rootElement = document.getElementById('root');
    const root = ReactDOM.createRoot(rootElement);
    root.render(<App />);
  </script>
</body>

In this example, a React component CustomCard is created that attaches a shadow DOM to a regular div. The Shadow DOM ensures that the styles and structure of the component are encapsulated, providing seamless integration with React.

When to Use Shadow DOM

Shadow DOM is not required for every component, so weigh it against the trade-offs:

  • Reach for it when you ship a self-contained, reusable widget — especially one used on pages whose global CSS you do not control (embeds, design-system primitives, third-party widgets).
  • Skip it when your component lives entirely inside an app that already scopes styles (CSS Modules, scoped styles, BEM) and you want global theming to flow in freely.
  • Watch out for these common pitfalls:
    • Global stylesheets and fonts do not cascade in automatically; declare what you need inside the root, or pass values in with CSS custom properties (--my-color), which do pierce the boundary.
    • Form-associated elements need extra wiring (the ElementInternals API) to participate in a surrounding <form>.
    • Server-side rendering of shadow trees requires Declarative Shadow DOM (<template shadowrootmode="open">).
Info

Rule of thumb: prefer mode: 'open' and expose theming hooks with ::part() and CSS custom properties. Use closed only when hiding internals is a real requirement.

Conclusion

Mastering Shadow DOM is essential for modern web development, providing powerful encapsulation and reusability. By understanding and implementing the concepts and examples provided, you can create robust, isolated components that enhance the maintainability and scalability of your web applications.

This comprehensive guide should serve as a solid foundation for exploring and utilizing Shadow DOM in your projects. Whether you are building simple widgets or complex applications, the Shadow DOM offers the encapsulation and flexibility needed to ensure your components remain isolated and manageable.

Practice

Practice
Which method is used to create a shadow root in JavaScript?
Which method is used to create a shadow root in JavaScript?
Practice
What does host.shadowRoot return when the root was created with mode: 'closed'?
What does host.shadowRoot return when the root was created with mode: 'closed'?
Practice
Which selector lets the outer document style only the internal parts a component explicitly exposes?
Which selector lets the outer document style only the internal parts a component explicitly exposes?
Was this page helpful?