Overriding Tokens Inside Shadow DOM with :host and ::part

Part of Component-Level Theme Overrides. Shadow DOM blocks selectors and lets custom properties through. That single asymmetry is the whole basis for theming an encapsulated component, and this guide covers how to use it deliberately.

What crosses a shadow boundary and what does not A shadow boundary drawn as a dashed line, with custom properties and inherited values passing through it, and selectors, class names and stylesheets blocked. shadow boundary custom properties inherited properties (color, font) descendant selectors global stylesheet rules inside the shadow root :host reads what came through internal selectors are private ::part exposes named elements outward
Encapsulation is one-directional and value-shaped. Everything a component wants to accept from outside must arrive as an inherited value or be exposed explicitly.

Prerequisites Link to this section

  • A component implemented as a custom element with an open shadow root; a closed root cannot expose parts.
  • A theme contract listing which component tokens are public, as described in the parent area.
  • Global tokens declared on :root so they inherit into every shadow tree.
  • A brand mechanism that sets an attribute on the root element, such as data-brand.

Step-by-Step Implementation Link to this section

Step 1 — Read global tokens through :host Link to this section

Intent: give the component local names for the values it consumes, so a brand overrides a name rather than a declaration.

/* component: ds-button.css, inside the shadow root */
:host {
  --btn-bg: var(--ds-color-action-primary, #2563eb);
  --btn-fg: var(--ds-color-on-action, #ffffff);
  --btn-radius: var(--ds-radius-md, 6px);

  display: inline-block;
}

button {
  background: var(--btn-bg);
  color: var(--btn-fg);
  border-radius: var(--btn-radius);
  border: 0;
  padding: 0.5rem 1rem;
  font: inherit;
}

Why this works: the :host block does two jobs at once. It declares the component’s public token names, and it supplies their defaults from the global layer with a literal fallback. A page that sets --ds-color-action-primary themes the button without knowing it exists; a brand that sets --btn-bg overrides just this component; and a component used in isolation with no design system present still renders.

Step 2 — Let a brand override the component token Link to this section

Intent: apply a per-brand value without any selector reaching inside the component.

/* brands/acme.css — no component class names appear here */
@layer brand {
  [data-brand="acme"] {
    --btn-radius: 0;
    --btn-bg: var(--ds-color-brand-primary);
  }
}

Why this works: custom properties inherit, so a declaration on the root element reaches the host of every ds-button in the document, and the :host rule’s declaration is overridden by the inherited value at the host. No part of this depends on the button’s internal markup, which is what keeps the component free to change.

Step 3 — Use :host() for state that lives on the host Link to this section

Intent: vary styling based on attributes the consumer sets on the element itself.

:host([variant="ghost"]) {
  --btn-bg: transparent;
  --btn-fg: var(--ds-color-action-primary);
}

:host([disabled]) {
  --btn-bg: var(--ds-color-surface-disabled);
  --btn-fg: var(--ds-color-text-disabled);
}

Why this works: :host() with an argument matches the host element when it also matches the selector inside the parentheses. This keeps variant logic inside the component where it belongs, rather than requiring the consumer to know which tokens a ghost button should set.

Step 4 — Reach outward with :host-context() sparingly Link to this section

Intent: respond to an ancestor’s state — a theme, a brand, a density mode — when the component genuinely needs to know.

/* Prefer inheritance. Use this only when a value cannot express the difference. */
:host-context([data-density="compact"]) button {
  padding: 0.25rem 0.5rem;
}

Why this works, and when not to use it: :host-context() lets a shadow rule match on an ancestor outside the boundary, which is powerful and directional in the wrong way — the component now depends on document structure it does not own. Support is also uneven across engines. In nearly every case a custom property carries the same information with none of the coupling, and the inherited-value route should be exhausted first.

Three selectors for styling a shadow host, compared A comparison of :host, :host() with an argument and :host-context, covering what each matches, what it couples the component to, and the recommended use. :host matches the host element always applies couples to: nothing use for: declaring the component's token defaults :host([attr]) matches when the host carries the attribute couples to: its own API use for: variants and states the consumer sets :host-context() matches on an ancestor outside the boundary couples to: the document use for: last resort, and check engine support first
The three differ mainly in what they make the component depend on. That dependency, not the syntax, is what should drive the choice.

Step 5 — Expose a part only when structure must be reachable Link to this section

Intent: name a specific internal element that a brand may style directly, and treat that name as public API.

<!-- inside the shadow root -->
<button part="control">
  <span part="label"><slot></slot></span>
  <span class="chevron" aria-hidden="true"></span>
</button>
/* brands/acme.css — allowed, because "label" is a published part */
ds-button::part(label) {
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

Why this works: ::part() punches a hole of exactly the size you choose. The chevron element above has no part attribute, so it remains private and can be renamed, restructured or removed. Publishing two parts commits you to two elements continuing to exist; publishing a part on every element is equivalent to having no encapsulation at all.

Step 6 — Keep the exposed surface in the contract Link to this section

Intent: parts and component tokens are API, so they belong in the same document as every other published name.

{
  "version": "1.2.0",
  "components": {
    "ds-button": {
      "tokens": ["--btn-bg", "--btn-fg", "--btn-radius"],
      "parts": ["control", "label"],
      "notes": "chevron is intentionally private"
    }
  }
}

Why this works: a machine-readable surface list lets the conformance check reject a brand file that reaches outside it, and lets a component build fail when a part attribute is added without a corresponding entry. Without the list, the surface is whatever brands happen to have discovered.

Verification Link to this section

Three assertions cover the mechanism. First, that a global token reaches the component: set --ds-color-action-primary on the root and read the button’s computed background. Second, that a brand override wins: set --btn-bg at the brand level and confirm the resolved value changes. Third, that private internals are genuinely private: attempt to style the chevron from outside and confirm nothing happens.

// tests/shadow-theming.spec.mjs
import { test, expect } from '@playwright/test';

test('global tokens inherit into the shadow root', async ({ page }) => {
  await page.goto('/gallery/ds-button/');
  const bg = await page.evaluate(() => {
    const host = document.querySelector('ds-button');
    const control = host.shadowRoot.querySelector('button');
    return getComputedStyle(control).backgroundColor;
  });
  expect(bg).not.toBe('rgba(0, 0, 0, 0)');
});

test('a brand override reaches the component without a selector', async ({ page }) => {
  await page.goto('/gallery/ds-button/');
  await page.evaluate(() => document.documentElement.setAttribute('data-brand', 'acme'));
  const radius = await page.evaluate(() => {
    const control = document.querySelector('ds-button').shadowRoot.querySelector('button');
    return getComputedStyle(control).borderRadius;
  });
  expect(radius).toBe('0px');
});

test('unexposed internals cannot be styled from outside', async ({ page }) => {
  await page.goto('/gallery/ds-button/');
  await page.addStyleTag({ content: 'ds-button .chevron { display: none }' });
  const visible = await page.evaluate(() => {
    const chevron = document.querySelector('ds-button').shadowRoot.querySelector('.chevron');
    return getComputedStyle(chevron).display !== 'none';
  });
  expect(visible).toBe(true);      // encapsulation held
});

The third test is the one worth keeping permanently. It fails the day someone converts the component from shadow DOM to light DOM, which is a change that silently turns every internal class name into public API.

Resolution order for a component token inside shadow DOM Four sources ordered by precedence: the literal fallback, the host default, the inherited global token, and the brand override, showing which wins. 4 · literal fallback in var(--ds-…, #2563eb) — used only when nothing else is defined 3 · :host default — the component's own choice of global token 2 · inherited global token set on :root — themes land here 1 · brand override on [data-brand] — inherits to the host and wins
Four layers, resolved by ordinary inheritance and cascade rules. Nothing here is shadow-specific except that the component chose which names to read.

Troubleshooting Link to this section

Symptom Likely cause Fix
A global token has no effect inside the component The component reads a different token name than the one being set Check the :host block; the component’s chosen names are the contract
::part() does nothing The part attribute is missing, or the shadow root is closed Parts require an open root and an exact attribute-value match
A brand override works in one component and not another One component declares the token with a more specific internal selector Declare component tokens only in :host, never on internal elements
Styles leak into the component unexpectedly The component uses light DOM, not shadow DOM Confirm attachShadow is called; light-DOM components have no encapsulation
:host-context() works in one browser only Uneven engine support Replace with an inherited custom property; the coupling was avoidable

Fallbacks Are Part of the Contract Link to this section

One detail in step 1 deserves more attention than it usually gets: the literal fallback inside var(--ds-color-action-primary, #2563eb).

A component that ships with no fallback is unusable outside the design system. Dropped into a plain page, a documentation example, an email preview or a third-party host application, every custom property resolves to nothing and the component renders as unstyled markup. That is a genuinely bad first impression for a component library, and it is entirely avoidable.

A component that ships with fallbacks renders correctly everywhere and looks correct inside the design system too, because the global token wins whenever it is defined. The fallback is a floor, not a default in the ordinary sense.

Choose the fallback values carefully, though, because they are a public commitment of a subtle kind: they are what the component looks like to anyone who has not adopted the token system. Picking neutral, accessible values — sufficient contrast, sensible spacing — means the standalone rendering is at least usable. Picking the design system’s current brand colour means the standalone rendering is a snapshot of a brand that will drift, and nobody will remember to update it.

The same reasoning applies to whether the fallback should exist at all for tokens that carry meaning. A --btn-bg with a blue fallback renders a blue button outside the system, which is fine. A --status-danger-bg with a blue fallback renders a danger state that does not read as dangerous, which is worse than rendering unstyled. For semantic tokens whose meaning is the point, a fallback that preserves the meaning — red, in that case — is the correct choice even though it duplicates a value.

Migration Note Link to this section

Converting a light-DOM component library to shadow DOM is disruptive precisely because it removes the ability to style internals from outside — which many consumers will be relying on without either side knowing.

Sequence it by measuring first. Search consuming repositories for selectors that target the component’s internal class names; each hit is a consumer that will break. That list is the initial part inventory: for each one, decide whether it should become a component token, an exposed part, or a supported variant. Most turn into tokens.

Then convert one component and ship it behind a flag, so the breakage surfaces in a controlled way. The components that convert cleanly are the ones nobody was reaching into, and they are worth doing first to build confidence in the pattern before touching the button.