W3docs

Shadow DOM Styling

Style encapsulated web components with the Shadow DOM: :host, :host(), :host-context(), ::slotted(), and CSS custom properties that pierce the boundary.

The Shadow DOM gives a component its own private DOM tree and its own private styles. This page focuses on the styling half: how CSS written inside a shadow root is encapsulated, the special selectors you use to reach the host and the slotted content (:host, :host(), :host-context(), ::slotted()), how to let outside CSS customize a component on purpose (custom properties), and the two ways to attach a stylesheet (<style> vs adoptedStyleSheets).

If the Shadow DOM itself is new to you, read JavaScript Shadow DOM first, and see Web Components for the bigger picture of where shadow roots fit.

Why styles are encapsulated

The core promise of the Shadow DOM is a two-way style boundary:

  • Outside CSS does not leak in. A page-wide p { color: red } rule will not touch a <p> inside a shadow root. This is what makes components safe to drop into any page.
  • Inside CSS does not leak out. Styles in a shadow root only apply within that root, so you can use short, generic selectors (button, p, .title) without worrying about clashing with the host page.

This is different from the regular styles and classes model, where every selector competes in one global scope. Inside a shadow root, scope is the default.

Creating a shadow root

To start, attach a shadow root to a host element. Everything you put inside it — markup and CSS — is encapsulated.

<body>
  <div id="my-element"></div>
    <script>
      // Creating Shadow DOM
      const shadowRoot = document.getElementById('my-element').attachShadow({ mode: 'open' });
    
      // Styling Shadow DOM
      shadowRoot.innerHTML = `
        <p>A simple shadow root content.</p>
      `;
    </script>
</body>

Here we attach a shadow root with the attachShadow() method and set its mode to 'open', which lets you read the root back later via element.shadowRoot. ('closed' hides it from outside scripts but does not add real security.)

Adding scoped styles with <style>

The simplest way to style a shadow root is to put a <style> element inside it. Those rules apply only within the root — and the page's rules stay out.

<div id="my-element">
  <!-- Shadow DOM content -->
</div>

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

  shadowRoot.innerHTML = `
    <style>
      /* Scoped styles */
      :host {
        display: block;
        border: 2px solid #333;
        padding: 10px;
      }

      p {
        color: blue;
      }
    </style>

    <p>This paragraph is styled within the Shadow DOM.</p>
  `;
</script>

The p { color: blue } rule only colors the paragraph inside this root — a <p> elsewhere on the page is untouched. The :host rule (below) styles the host element itself.

Targeting the host: :host, :host(), :host-context()

A shadow root cannot select its host element with a normal selector, because the host lives outside the root. Three pseudo-classes bridge that gap:

SelectorMatchesUse it for
:hostThe host element, alwaysBase styles for the component (display, padding, box).
:host(<selector>)The host only when it matches <selector>Variants and states driven by attributes/classes/pseudo-classes, e.g. :host([disabled]), :host(:hover).
:host-context(<selector>)The host when an ancestor matches <selector>Adapt to context, e.g. :host-context(.dark-theme).
<div class="dark-theme">
  <fancy-box disabled>Boxed content</fancy-box>
</div>

<script>
  class FancyBox extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' }).innerHTML = `
        <style>
          :host {
            display: block;
            padding: 12px;
            border: 2px solid #007bff;
          }
          /* Variant: applies only when the host has [disabled] */
          :host([disabled]) {
            opacity: 0.5;
            pointer-events: none;
          }
          /* Context: applies when any ancestor has .dark-theme */
          :host-context(.dark-theme) {
            background: #1e1e1e;
            color: #fff;
          }
        </style>
        <slot></slot>
      `;
    }
  }
  customElements.define('fancy-box', FancyBox);
</script>

Because the host element starts with [disabled] and sits inside .dark-theme, all three rules apply: it renders dark, dimmed, and non-interactive.

Warning

:host-context() has limited browser support (no Firefox at the time of writing). Prefer a CSS custom property or an explicit attribute on the host when you need broad compatibility.

Styling slotted content with ::slotted()

Content the user passes into your component lives in the light DOM and is rendered through a <slot>. Such content keeps belonging to the page, so the page's own styles win — but you can still reach it from inside the shadow root with ::slotted().

One important limit: ::slotted() only matches the top-level slotted nodes, not their descendants. ::slotted(span) works; ::slotted(div span) does not.

<body>
<script>
  class CustomButton extends HTMLElement {
    constructor() {
      super();
      const shadowRoot = this.attachShadow({ mode: 'open' });

      shadowRoot.innerHTML = `
        <style>
          :host {
            display: inline-block;
            padding: 10px 20px;
            background-color: #007bff;
            color: #fff;
            border: none;
            cursor: pointer;
          }

          :host(:hover) {
            background-color: #0056b3;
          }

          button {
            font-weight: bold;
            border: none;
            background: none;
            color: inherit;
            cursor: inherit;
            padding: 0;
          }

          /* Styling slotted content */
          ::slotted(span) {
            font-style: italic;
            text-decoration: underline;
          }
        </style>

        <button>
          <slot></slot>
        </button>
      `;
    }
  }

  customElements.define('custom-button', CustomButton);
</script>

<!-- Test custom-button with slotted content -->
<custom-button id="my-button">Click <span>here</span></custom-button>
</body>

Here ::slotted(span) targets the <span> passed in as slot content, italicizing and underlining it, while the surrounding "Click" text is left alone.

Letting the page customize a component: CSS custom properties

Encapsulation is great, but it can feel like a wall: the host page cannot reach inside to recolor a button. The intended escape hatch is CSS custom properties (variables) — they are the one thing that does pierce the shadow boundary. The component reads a variable with var() and supplies a fallback; the page sets that variable from outside.

<style>
  /* The page customizes the component from outside the boundary */
  theme-button {
    --btn-bg: #28a745;
    --btn-bg-hover: #1e7e34;
  }
</style>

<theme-button>Save</theme-button>

<script>
  class ThemeButton extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' }).innerHTML = `
        <style>
          :host {
            /* var(--name, fallback): fallback is used if the page sets nothing */
            background: var(--btn-bg, #007bff);
            color: #fff;
            padding: 8px 16px;
            display: inline-block;
            cursor: pointer;
          }
          :host(:hover) {
            background: var(--btn-bg-hover, #0056b3);
          }
        </style>
        <slot></slot>
      `;
    }
  }
  customElements.define('theme-button', ThemeButton);
</script>

The button renders green because the page set --btn-bg. Remove those two declarations and it falls back to blue (#007bff). This is the cleanest way to expose a theming API while keeping the component's internals private.

<style> vs adoptedStyleSheets

Putting a <style> tag in every instance's innerHTML works, but it duplicates the CSS text for each component on the page and forces the browser to re-parse it every time. For components you create many times, share a single parsed CSSStyleSheet across roots with adoptedStyleSheets.

<my-badge>New</my-badge>
<my-badge>Beta</my-badge>

<script>
  // Parsed once, reused by every instance
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(`
    :host {
      display: inline-block;
      padding: 2px 8px;
      border-radius: 999px;
      background: #007bff;
      color: #fff;
      font-size: 12px;
    }
  `);

  class MyBadge extends HTMLElement {
    constructor() {
      super();
      const root = this.attachShadow({ mode: 'open' });
      root.adoptedStyleSheets = [sheet]; // adopt the shared sheet
      root.innerHTML = `<slot></slot>`;
    }
  }
  customElements.define('my-badge', MyBadge);
</script>

When to use which:

  • <style> inside the root — simplest, no extra code, fine for one-off components or small demos.
  • adoptedStyleSheets — preferred when the same component appears many times: one shared, constructable stylesheet means less memory and faster instantiation. You can also update the sheet at runtime (sheet.replaceSync(...)) and every adopting root reflects the change instantly.

Conclusion

Shadow DOM styling rests on a few ideas: styles are scoped both ways, you reach the host with :host / :host() / :host-context(), you reach projected content with ::slotted(), you expose theming through CSS custom properties, and you attach CSS either inline with <style> or efficiently with adoptedStyleSheets. Together they let you ship components that look right anywhere yet stay customizable on your terms.

To go further, see Shadow DOM Slots & Composition for how slotted content is assembled, and Web Components for combining shadow roots with custom elements and templates.

Practice

Practice
What feature of the Shadow DOM allows developers to style content projected into custom elements?
What feature of the Shadow DOM allows developers to style content projected into custom elements?
Was this page helpful?