ESC

Type to search the knowledge base.

Shadow DOM Basics

Encapsulate markup and styles with shadow roots — open vs closed, slots, CSS boundaries, and events retargeting.

advanced3 min read
  • javascript
  • shadow-dom

Global CSS and querySelector see the whole document — until Shadow DOM creates a scoped subtree. Web components use it so a widget’s internals don’t clash with the page, and page CSS doesn’t accidentally restyle the widget’s guts.

class FancyButton extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <style>
        button {
          background: rebeccapurple;
          color: white;
          border: 0;
          padding: 0.5rem 1rem;
        }
      </style>
      <button part="control"><slot></slot></button>
    `;
  }
}
customElements.define('fancy-button', FancyButton);
<fancy-button>Save</fancy-button>

open vs closed

Mode element.shadowRoot Use
'open' accessible default for most apps
'closed' null from outside weak hiding; not real security

Closed mode stops casual access; determined code can still keep the reference from attachShadow’s return value. Not a security boundary.

Style encapsulation

  • Styles inside the shadow root apply to the shadow tree.
  • Page selectors don’t pierce in (except documented escape hatches).
  • :host styles the host element from inside.
  • ::slotted() styles distributed light-DOM children (limited).
  • ::part() + part attributes let pages theme specific internals deliberately.
/* page CSS */
fancy-button::part(control) {
  border-radius: 8px;
}

CSS variables inherit into shadow trees — primary theming channel:

fancy-button {
  --fb-bg: navy;
}
/* inside shadow */
button { background: var(--fb-bg, rebeccapurple); }

Slots (light DOM projection)

<!-- in shadow -->
<slot name="icon"></slot>
<slot></slot> <!-- default -->

<!-- usage -->
<fancy-button>
  <span slot="icon">★</span>
  Save
</fancy-button>

Slotted nodes stay in the light DOM (children of the host) for accessibility and SEO; they’re rendered at slot positions.

Events

Events retarget so external listeners see the host as the target when the real target is inside the shadow tree (for non-composed details). Use { composed: true } on CustomEvent if you need to cross the boundary.

this.dispatchEvent(new CustomEvent('save', { bubbles: true, composed: true }));

vs iframe

Shadow DOM shares the JS realm and is lighter; iframes are hard origin isolation. Pick shadow for widgets; iframes for untrusted third-party documents.

Interview answer (out loud)

“Shadow DOM attaches a scoped tree to an element so internal markup and styles don’t leak either way by default. open mode exposes shadowRoot; slots project light DOM children; CSS variables and part enable controlled theming. Events may retarget; composed events cross the boundary.”

Form elements and shadow

Native form association across shadow roots has improved (form attribute, ElementInternals). If you build custom inputs, read up on form-associated custom elements — shadow encapsulation otherwise breaks FormData expectations.

Focus and tabs

Shadow trees participate in focus; delegatesFocus option on attachShadow helps hosts forward focus into internals.

this.attachShadow({ mode: 'open', delegatesFocus: true });

Querying

host.shadowRoot.querySelector('.inner');
// document.querySelector('.inner') won't see it

Tests must pierce shadow roots intentionally (shadowRoot) or use user-facing roles via Testing Library, which is usually better.

Further reading

Related guides