Shadow DOM styling

A shadow tree can carry its own CSS. You put it in a <style> block, or you link an external sheet with <link rel="stylesheet" href="…">. The linked variant is worth knowing about: because the browser caches HTTP responses, ten components that all reference the same stylesheet URL download it once, not ten times. That keeps the network cost flat no matter how many instances of the component land on the page.

The mental model to hold onto is a wall. Styles written inside the shadow tree stay inside it. Styles written in the main document stay outside it. Neither leaks across the boundary by default — which is the entire point of Shadow DOM. But “by default” is doing work in that sentence. There are a handful of deliberate openings in the wall, and this article is a tour of each one.

Light DOM (document)
<style> document rules
<user-card> ← the host
slotted content lives here
Shadow DOM (component)
<style> local rules
:host → styles the host
::slotted() → reaches slotted nodes
–vars pierce through
The shadow boundary keeps two style scopes apart, with a few controlled crossings

:host — styling the host from the inside

The host is the element that owns the shadow tree — the <custom-dialog> or <user-card> tag itself. It sits in the light DOM, so from the outside you can target it with a normal selector. But sometimes the component wants to style itself from within its own shadow CSS. That’s what :host is for.

Say you’re building a <custom-dialog> that should sit dead-center on the screen. The centering has to apply to the host element, and the rule for it belongs with the component, not scattered in whatever page uses it. :host selects the host from inside the shadow tree:

<template id="tmpl">
  <style>
    /* applied to the custom-dialog host element from inside */
    :host {
      position: fixed;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      display: inline-block;
      border: 1px solid red;
      padding: 10px;
    }
  </style>
  <slot></slot>
</template>

<script>
customElements.define('custom-dialog', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'}).append(tmpl.content.cloneNode(true));
  }
});
</script>

<custom-dialog>
  Hello!
</custom-dialog>

Cascading — who wins when both sides style the host

Here’s the subtle part. The host lives in the light DOM, so it’s reachable by both the document’s CSS and the shadow tree’s :host rule. When the same property is set in both places, which value applies?

The document wins.

document: custom-dialog { padding: 0 }
beats
:host { padding: 10px }
document: padding: 0
loses to
:host { padding: 10px !important }
Property conflict on the host: the document's value beats :host

So if the page contains this:

<style>
custom-dialog {
  padding: 0;
}
</style>

…the dialog ends up with no padding, even though :host asked for 10px. That’s a feature. Treat your :host rules as defaults — sensible starting values that any page can override without fighting specificity. It’s the same spirit as a function’s default parameters.

The one escape hatch: mark a local property !important and it flips the priority back the other way. An !important value inside :host can no longer be overridden by a plain document rule. Use it sparingly — a component that forces its styles gives its users nothing to override.

See the rule for yourself. The component’s :host asks for a light-blue background; the page asks for yellow. The page wins. Then hit Edit code, add !important to the :host background line, and re-run — priority flips and the box goes blue again.

interactive:host is an overridable default

:host(selector) — conditional host styling

:host(selector) is :host with a filter. The rule only applies when the host itself matches the selector inside the parentheses. That selector is tested against the host element, so it’s perfect for attributes, classes, or states on the host.

Suppose you only want the centering when the dialog carries a centered attribute:

<template id="tmpl">
  <style>
    :host([centered]) {
      position: fixed;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      border-color: blue;
    }

    :host {
      display: inline-block;
      border: 1px solid red;
      padding: 10px;
    }
  </style>
  <slot></slot>
</template>

<script>
customElements.define('custom-dialog', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'}).append(tmpl.content.cloneNode(true));
  }
});
</script>

<custom-dialog centered>
  Centered!
</custom-dialog>

<custom-dialog>
  Not centered.
</custom-dialog>

The first dialog has centered, so it picks up the fixed-position, blue-border rule. The second doesn’t, so it just gets the plain :host defaults. One component, two looks, driven entirely by an attribute on the host.

Here the same idea drives a <status-badge>: a plain grey pill normally, but a red one whenever the host carries an urgent attribute. The button toggles that attribute so you can watch :host([urgent]) switch on and off.

interactiveOne attribute, two looks

The takeaway for the whole :host family: it styles the component’s outer element, and unless you reach for !important, the document can always override what it does.

Styling slotted content

Slots complicate the picture. A slotted element is physically part of the light DOM — it’s written by the page, it just gets rendered at the slot’s location. Because it stays in the light DOM, it obeys document styles, and your shadow CSS does not reach into it.

Watch what happens here. The document says spans are bold; the shadow tree says spans are red:

<style>
  span { font-weight: bold }
</style>

<user-card>
  <div slot="username"><span>Maya Vance</span></div>
</user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <style>
      span { background: red; }
      </style>
      Name: <slot name="username"></slot>
    `;
  }
});
</script>

The name comes out bold but not red. The document’s span rule reaches the slotted span; the shadow tree’s span rule doesn’t.

document
span { font-weight: bold }
──✓──▶
<span>Maya Vance</span>
(slotted, light DOM)
◀──✗──
shadow
span { background: red }
A slotted node stays in the light DOM — document CSS reaches it, shadow CSS can't

So how does a component style what gets slotted into it? Two routes.

Route 1: style the slot, lean on inheritance

You can style the <slot> element itself. Inheritable properties (like font-weight, color, font-family) flow from the slot down into whatever is slotted through it:

<user-card>
  <div slot="username"><span>Maya Vance</span></div>
</user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <style>
      slot[name="username"] { font-weight: bold; }
      </style>
      Name: <slot name="username"></slot>
    `;
  }
});
</script>

The name renders bold because font-weight is an inherited property, and inheritance carries it from the slot to the content shown there. The catch: this only works for properties that inherit. Set border or background on the slot and the slotted content won’t pick them up, because those don’t inherit.

Route 2: ::slotted(selector)

The ::slotted(selector) pseudo-element targets slotted nodes directly. A match requires two things:

  1. The element is slotted content coming from the light DOM (any slot name — it doesn’t care which). And it matches the top-level slotted element only, not that element’s descendants.
  2. The element matches selector.

In this markup, ::slotted(div) hits the <div slot="username"> but stops there — it never reaches the inner <div>:

<user-card>
  <div slot="username">
    <div>Maya Vance</div>
  </div>
</user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <style>
      ::slotted(div) { border: 1px solid red; }
      </style>
      Name: <slot name="username"></slot>
    `;
  }
});
</script>
<div slot=“username”> ← ::slotted(div) matches this ✓
<div>Maya Vance</div> ← NOT reachable ✗
::slotted matches the slotted element itself — never its children

The boundary is strict. ::slotted can name the slotted element, but it can’t drill any deeper. Both of these are invalid:

::slotted(div span) {
  /* our slotted <div> does not match this */
}

::slotted(div) p {
  /* can't go inside light DOM */
}

One more limit: ::slotted is a CSS-only construct. There’s no matching call in querySelector — you can’t do shadowRoot.querySelector('::slotted(div)').

The button below toggles a :host([hi]) ::slotted(div) outline. Watch carefully: the red outline lands on the outer slotted div, but the nested div inside it (drawn with a dashed grey border) never gets one — proving ::slotted stops at the top-level slotted node.

interactive::slotted reaches the top node only

CSS hooks with custom properties

Everything so far styles either the host or the slotted content. But what about the elements inside the shadow tree — the component’s private guts? A page has no selector that reaches into another element’s shadow DOM. The encapsulation is real; you can’t write user-card .field &#123; ... &#125; and expect it to land inside the shadow tree.

That would make components rigid — you’d be stuck with whatever colors the author baked in. The escape valve is CSS custom properties (CSS variables).

Custom properties pierce the shadow boundary. A variable defined anywhere in the light DOM is visible to shadow trees below it in the tree, and shadow CSS can read it with var(). This turns variables into a published styling API: the component decides which knobs to expose, and the page turns them.

document: user-card { –user-card-field-color: green }

│ pierces the shadow boundary ▼

shadow: .field { color: var(–user-card-field-color, black) }

resolves to green (falls back to black if unset)

A custom property set on the host flows down into the shadow tree's var() lookup

Inside the shadow tree, the component reads a variable and supplies a fallback for when nobody sets it:

<style>
  .field {
    color: var(--user-card-field-color, black);
    /* if --user-card-field-color is not defined, use black */
  }
</style>
<div class="field">Name: <slot name="username"></slot></div>
<div class="field">Birthday: <slot name="birthday"></slot></div>

The page then sets that variable on the host (or anywhere above it):

user-card {
  --user-card-field-color: green;
}

Because the property is inherited and crosses the boundary, the inner .field rule picks it up and the fields turn green. Here’s the whole thing wired together:

<style>
  user-card {
    --user-card-field-color: green;
  }
</style>

<template id="tmpl">
  <style>
    .field {
      color: var(--user-card-field-color, black);
    }
  </style>
  <div class="field">Name: <slot name="username"></slot></div>
  <div class="field">Birthday: <slot name="birthday"></slot></div>
</template>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.append(document.getElementById('tmpl').content.cloneNode(true));
  }
});
</script>

<user-card>
  <span slot="username">Maya Vance</span>
  <span slot="birthday">14.03.1998</span>
</user-card>

The colour picker below sets --user-card-field-color on the host from the page. Because custom properties inherit straight through the shadow boundary, the var() inside the component picks up the new value instantly — the page themes the component without ever touching its internal class names.

interactiveA custom property pierces the boundary

Summary

Shadow DOM can carry styles through a <style> block or a <link rel="stylesheet"> (linked sheets get HTTP-cached, so shared URLs download once).

Local (shadow) styles can reach:

  • the shadow tree itself,
  • the shadow host, via :host and :host(selector),
  • slotted elements from the light DOM, via ::slotted(selector) — the slotted element itself, never its children.

Document styles can reach:

  • the shadow host, since it lives in the outer document,
  • slotted elements and everything inside them, since that content also lives in the outer document.

When a property is set on both sides for the host, the document wins — unless the local rule is !important, which flips priority to the shadow side. This makes :host rules act as overridable defaults.

CSS custom properties pierce the shadow boundary and become the sanctioned way to theme a component:

  1. Inside, the component styles key elements with var(--component-name-thing, <default>).
  2. The author publishes those property names as part of the component’s public surface.
  3. To restyle, a developer sets --component-name-thing on the host or any ancestor.
  4. The value flows in, and the component adapts.