A component's button is visible on the page, but document.querySelector() cannot find it. An outside click listener runs, yet its event.target points to the component rather than the button. Meanwhile, a font setting from the page still affects the component. None of these behaviors contradict Shadow DOM encapsulation.

A shadow root creates a boundary for DOM queries, CSS selectors, and event handling. It does not make the contents secret, block all inherited styles, or turn a custom element into an accessible component automatically. The useful question is which parts of the boundary are isolated and which are intentionally connected to the page.

Three trees to keep separate

Consider this markup:

<reveal-note>
  <span slot="details">The deployment finished successfully.</span>
</reveal-note>

The <reveal-note> element is the shadow host. Its author can attach a shadow root containing the internal button and a <slot name="details">. The span stays in the page's light DOM as a child of <reveal-note>; slot assignment affects where it is rendered, not its parent node.

That gives us three useful views:

ViewWhat it containsTypical question
Light DOMThe <reveal-note> host and its child spanWhat did the page provide?
Shadow treeThe component's button, wrapper, and slotWhat does the component implement?
Flattened treeInternal elements displayed with assigned slot contentWhat gets rendered?

A custom element, a shadow root, and an HTML template are related platform features, not synonyms. Custom elements provide registration and lifecycle hooks. Shadow DOM provides an optional encapsulated tree. Templates and slots help reuse markup and insert externally owned content. You can create a custom element without using Shadow DOM at all. See MDN's Web Components overview.

Build a component that exposes a small contract

The example is a disclosure-like note with a native button, one named slot, and a custom event. Save the following as shadow-note.html and open it in a current browser. It uses no library or build step.

<!doctype html>
<html lang="en">
  <meta charset="utf-8">
  <title>Shadow DOM boundary example</title>

  <template id="note-template">
    <style>
      :host { display: block; font: inherit; }
      .frame { border: 1px solid #777; padding: 1rem; }
      button { font: inherit; background: var(--note-accent, #254a81); color: white;
               border: 0; padding: 0.5rem 0.75rem; cursor: pointer; }
      button:focus-visible { outline: 3px solid orange; outline-offset: 2px; }
      [hidden] { display: none; }
      ::slotted([slot="details"]) { display: block; margin-top: 0.75rem; }
    </style>
    <section class="frame" part="frame">
      <button type="button" aria-expanded="false" aria-controls="details">
        Show details
      </button>
      <div id="details" hidden>
        <slot name="details">No details provided.</slot>
      </div>
    </section>
  </template>

  <reveal-note style="--note-accent: #205b47">
    <span slot="details">The deployment finished successfully.</span>
  </reveal-note>

  <script>
    const template = document.querySelector('#note-template');

    class RevealNote extends HTMLElement {
      constructor() {
        super();
        const root = this.attachShadow({ mode: 'open' });
        root.append(template.content.cloneNode(true));

        const button = root.querySelector('button');
        const details = root.querySelector('#details');

        button.addEventListener('click', () => {
          const open = button.getAttribute('aria-expanded') !== 'true';
          button.setAttribute('aria-expanded', String(open));
          button.textContent = open ? 'Hide details' : 'Show details';
          details.hidden = !open;

          button.dispatchEvent(new CustomEvent('note-toggle', {
            bubbles: true,
            composed: true,
            detail: { open }
          }));
        });
      }
    }

    customElements.define('reveal-note', RevealNote);

    document.addEventListener('note-toggle', (event) => {
      console.log(event.target.tagName, event.detail.open);
    });
  </script>
</html>

Each instance gets its own shadow root. Cloning template.content matters: appending the template's original fragment would move its children, leaving nothing for the next instance. The constructor creates the internal tree and registers a listener once; it does not read the host's children or attributes, which may not be ready during construction. For more elaborate components, use lifecycle callbacks when setup depends on connection state. MDN documents the constructor and lifecycle constraints.

The button retains native keyboard activation and has an aria-expanded state tied to the internal details container. This is a teaching example, not a full design-system component: product code still needs keyboard, screen-reader, theming, and integration tests in its actual context.

Probe the boundary in DevTools

After opening the file, paste these expressions into the console:

const note = document.querySelector('reveal-note');

document.querySelector('reveal-note button');       // null
note.shadowRoot.querySelector('button');           // the internal button
note.querySelector('[slot="details"]');           // the light DOM span
note.shadowRoot.querySelector('slot').assignedElements();
// [span] (the assigned light DOM element)

The outside document query does not traverse a shadow root. Because this example uses mode: 'open', code can explicitly inspect note.shadowRoot. With mode: 'closed', the shadowRoot property returns null to outside code. Closed does not mean secure: it is an encapsulation convention, not a boundary for secrets or hostile scripts. See MDN's Shadow DOM guide.

The slotted span is still found through note.querySelector() because it has not moved into the shadow tree. This distinction matters when debugging a component that appears to contain a node visually but cannot select it from the expected root.

What styling actually crosses the boundary?

Normal page selectors such as reveal-note button do not match the internal button. Likewise, a rule for .frame inside the shadow root does not select unrelated .frame elements in the page. That is selector scoping, not complete visual isolation.

The component still participates in CSS inheritance, including inherited properties such as color and font-family. In the example, font: inherit on :host makes the host follow its context. CSS custom properties can also flow into the shadow tree: the page sets --note-accent, and the internal button consumes it. That is an intentional theming API.

Two other styling hooks are worth distinguishing. The page can style an explicitly exported part in its own stylesheet:

reveal-note::part(frame) { border-radius: 0.75rem; }

The component author can style assigned light DOM elements using ::slotted(), but only from a stylesheet inside the shadow root. The template above already contains an example:

/* Inside the template's <style>, not the page stylesheet */
::slotted([slot="details"]) { display: block; margin-top: 0.75rem; }

::part(frame) works only because the component author put part="frame" on the section. ::slotted() targets assigned elements themselves, not arbitrary descendants deep inside them. An outer page rule can still style its own light DOM span. These are public styling surfaces that the component author should document rather than accidental leaks. See MDN on CSS shadow parts and templates and slots.

Why the outside event target is different

Click the internal button and watch the console. The handler on document receives the custom note-toggle event, but its event.target is the <reveal-note> host. The browser retargets events at the boundary so that outside listeners do not depend on the component's internal button.

This event deliberately uses both bubbles: true and composed: true. Bubbling lets it travel upward; composed allows an event dispatched inside the shadow tree to cross the boundary. A newly constructed custom event does not cross by default. Native user-generated click events are normally composed, so they can also reach outer listeners, but an outer listener generally sees the host as the target.

When debugging an open shadow tree, event.composedPath() can show the internal nodes along the route. Closed roots conceal their internals from the path visible outside. For a reusable component, prefer a stable, documented event such as note-toggle with detail.open over making consumers inspect internal buttons. MDN explains composed events and the event path.

This also changes how you use event delegation in dynamic interfaces: an outside closest('button') search is not a reliable way to identify a button inside someone else's shadow root. Handle the component's public event or API instead.

A boundary checklist for component authors

Before shipping a reusable custom element, verify the specific contract rather than assuming that Shadow DOM handles every concern:

  • DOM: Which nodes belong to the shadow tree, and which are slotted light DOM? Can consumers depend on a documented public property or event instead of querying internal markup?
  • CSS: Which custom properties or part names are supported? Are inherited fonts, text direction, focus outlines, and forced-colors behavior acceptable?
  • Events: Does a custom event need to cross the shadow boundary? If so, are bubbles and composed set appropriately? What does an external listener see as target?
  • Accessibility: Do internal controls use native elements and exposed states? Do keyboard navigation and screen readers work when the component is used in a real page?
  • Rendering: Does your integration require server-rendered content before JavaScript runs? If so, evaluate declarative Shadow DOM and your framework's hydration behavior instead of assuming client-side attachShadow() covers it.

Shadow DOM is most useful when the component has a deliberate contract across its boundary: content through slots, styling through custom properties or parts, and behavior through properties and events. Without that contract, encapsulation can make debugging and integration harder rather than easier.