Get started

Design tokens in Shadow DOM: define, override, and verify the styling contract

A CSS custom property may inherit into a shadow tree, but that does not make it a supported component API. Treat the boundary as a contract. Document which host properties consumers may override, keep internal aliases private by policy, and verify every hop from source token to rendered element.

Updated September 23, 2026

Inheritance is availability, not an API

Shadow DOM scopes selectors and internal structure. Selected CSS values still cross the boundary through inheritance, including CSS custom properties. A value declared on :root may reach a component host and descendants in its shadow tree when they stay in the inheritance chain and no nearer declaration changes the result.

That behavior shows only that the value is available under the tested conditions. It doesn't mean the component owner promises to keep reading the name, permits consumers to override it, or guarantees that it affects every mode and state. Those are component API decisions.

Keep the claim narrow

Don't claim that every component sees every root property. A nearer semantic scope, a direct declaration on the host, a component mapping, or a different fallback path can change the result. Inspect the actual chain.

Separate four responsibilities

  1. Upstream semantic source: owns the meaning and light and dark values, such as the surface and content colors for a success status.
  2. Emitted runtime property: carries that decision into application CSS. Its presence is artifact evidence, not proof that a component consumes it.
  3. Public component property: gives consumers a documented, component-scoped override point such as --chip-background.
  4. Private internal alias: connects the public property to a named element inside the shadow tree. It remains an implementation detail even when its computed value can be inspected.

A component may also inherit an ordinary property such as color, expose a CSS part, or hard-code an internal rule. Each surface has a different contract. Calling all of them tokens hides who may change what.

Write the styling contract before the CSS

This contract describes the illustrative status chip used throughout the guide. It is a proposed record, not evidence from an executed fixture. Copy it into the component repository, then replace the example identities, owners, and policies with project data.

Background propertyForeground propertyRequired record
Upstream authorityApplication design-system ownerApplication design-system ownerName the versioned source, not merely a team.
Semantic roleSuccess-status surfaceSuccess-status contentDescribe intent rather than a color name.
Emitted name--app-status-success-bg--app-status-success-fgConfirm both names in the delivered stylesheet.
Component API name--chip-background--chip-foregroundDocument these on my-status-chip.
Interface statusPublicPublicAllowed classifications are public, private, or accidental.
Host scopemy-status-chipmy-status-chipOverrides must declare the public property directly on the host in this fixture.
Component defaultvar(--app-status-success-bg, #e8f5ec)var(--app-status-success-fg, #184c2f)The component owner controls these illustrative fallbacks.
Fallback ownerComponent ownerComponent ownerChanging a fallback is a component-contract change.
Allowed overridesAny documented selector that matches the component host directlyAny documented selector that matches the component host directlyAn ancestor-only declaration of the public property is not a supported override here.
ModesLight and dark behind one semantic nameLight and dark behind one semantic nameThe application owns mode selection and delivery.
StatesSuccess default; warning maps to its own roleSuccess default; warning maps to its own roleVerify every supported state separately.
Named consumer.chip background-color.chip colorName the internal element and consuming CSS property.
Compatibility policyBreaking if removed or semantics changeBreaking if removed or semantics changeRecord deprecation and replacement rules.
Evidence statusExpected, unexecutedExpected, unexecutedChange only after recording an observation.
Correction ownerUnassigned until the failing layer is identifiedUnassigned until the failing layer is identifiedDon't infer ownership from the visible symptom.
Copyable styling-contract matrix for the status-chip foreground and background pair

An underscore is documentation, not access control

A name such as --_chip-bg signals private intent to maintainers, but CSS doesn't enforce the convention. Consumers may still declare that name outside the component. Privacy comes from the documented contract, compatibility policy, and an implementation that declares the alias beside its internal consumer.

Build one complete, explicitly unexecuted fixture

The inert code specimen below uses plain custom elements, an open shadow root for inspection, and CSS custom properties. Its markup delimiters are escaped so the publication adapter treats the specimen as data instead of executable page content. Decode the entities only in an isolated local test file. This isn't an executed Identity Forge integration or a browser benchmark.

<style>
  :root {
    --app-status-success-bg: #e8f5ec;
    --app-status-success-fg: #184c2f;
    --app-status-warning-bg: #fff4cc;
    --app-status-warning-fg: #5f4300;
  }

  [data-theme="dark"] {
    --app-status-success-bg: #173d2a;
    --app-status-success-fg: #c7f2d7;
    --app-status-warning-bg: #4a3700;
    --app-status-warning-fg: #ffe8a3;
  }

  .review-scope {
    --app-status-success-bg: #dceeff;
    --app-status-success-fg: #123b63;
  }

  /* Supported application overrides match the host directly. */
  .review-scope > my-status-chip.host-override {
    --chip-background: #244f73;
    --chip-foreground: #f4f9ff;
  }

  #direct-chip {
    --chip-background: #3b245f;
    --chip-foreground: #f7efff;
  }

  /* This ancestor-only public declaration is a precedence test,
     not a supported override in this fixture. */
  .ancestor-public-test {
    --chip-background: #7a2e2e;
    --chip-foreground: #fff4f4;
  }
</style>

<section data-theme="light">
  <my-status-chip id="default-chip">Ready</my-status-chip>

  <div class="review-scope">
    <my-status-chip id="scoped-chip">Scoped semantic values</my-status-chip>
    <my-status-chip id="matched-host-chip" class="host-override">Matched host override</my-status-chip>
  </div>

  <div class="ancestor-public-test">
    <my-status-chip id="ancestor-public-chip">Ancestor public declaration</my-status-chip>
  </div>

  <my-status-chip id="direct-chip">Direct override</my-status-chip>
  <my-status-chip id="warning-chip" status="warning">Review</my-status-chip>
  <status-cluster id="nested-cluster"></status-cluster>
</section>

<script>
  class MyStatusChip extends HTMLElement {
    constructor() {
      super();
      const root = this.attachShadow({ mode: "open" });
      root.innerHTML = `
        <style>
          :host {
            display: inline-block;
            --chip-background: var(
              --app-status-success-bg,
              #e8f5ec
            );
            --chip-foreground: var(
              --app-status-success-fg,
              #184c2f
            );
          }

          :host([status="warning"]) {
            --chip-background: var(
              --app-status-warning-bg,
              #fff4cc
            );
            --chip-foreground: var(
              --app-status-warning-fg,
              #5f4300
            );
          }

          .chip {
            --_chip-bg: var(--chip-background, #e8f5ec);
            --_chip-fg: var(--chip-foreground, #184c2f);
            display: inline-flex;
            align-items: center;
            border-radius: 999px;
            padding: 0.25rem 0.625rem;
            background-color: var(--_chip-bg);
            color: var(--_chip-fg);
          }
        </style>
        <span class="chip" data-consumer="chip"><slot></slot></span>
      `;
    }
  }

  class StatusCluster extends HTMLElement {
    constructor() {
      super();
      const root = this.attachShadow({ mode: "open" });
      root.innerHTML = `
        <style>
          :host {
            display: block;
            --app-status-success-bg: #edf0ff;
            --app-status-success-fg: #292f6b;
          }
        </style>
        <my-status-chip id="nested-chip">Nested</my-status-chip>
      `;
    }
  }

  customElements.define("my-status-chip", MyStatusChip);
  customElements.define("status-cluster", StatusCluster);
</script>
Escaped, inert plain Web Components fixture. Its values and expected behavior have not been executed or observed.

The background path starts with --app-status-success-bg in application CSS, passes through --chip-background on the host and --_chip-bg on .chip, then reaches background-color on that element. The foreground follows the same structure. The nested cluster supplies different semantic values in its shadow tree, while the nested chip consumes the same public component names.

The distinction between semantic scope and component override matters. .review-scope changes inherited application semantic properties, so the component's :host mapping sees different source values. By contrast, .ancestor-public-test only places --chip-background on an ancestor. The component declares the same public property on its own host, so the inherited ancestor value isn't the supported override path. .review-scope > my-status-chip.host-override and #direct-chip match the host itself and are the supported external overrides in this fixture.

Expected, not observed

The fixture is a reproducible test proposal. When you execute it, record the browser, operating system, component version, stylesheet identity, and actual computed values. Don't turn the expectations below into pass claims until those observations exist.

Test precedence with an expectation-first matrix

Run one condition at a time, resetting the fixture between cases. Compare normalized computed colors because a browser may serialize them differently from the notation in the stylesheet.

Setup and expected traceInspectFailure condition
Document defaultIn light mode, the success semantic pair should reach the public pair, private pair, and .chip.Stylesheet, host, internal alias, rendered foreground and background.Any hop is missing or resolves to a different normalized color.
Container semantic scopePlace the chip in .review-scope; its nearer semantic pair should feed the same host mapping.Container semantic values, host public values, internal aliases, rendered values.The semantic source changes but a downstream hop remains on the document default.
Host-matching application overrideUse .review-scope > my-status-chip.host-override so the rule declares both public properties directly on the host.Matched selector, host public pair, internal pair, rendered pair, unaffected sibling.The selector does not match, the target ignores the declaration, or a sibling changes.
Direct host ID overrideSet both public properties on #direct-chip; they should replace the semantic mapping for that host only.Host public pair, internal pair, rendered pair, unaffected sibling.The target ignores the override or a sibling changes.
Ancestor public declarationPlace the chip inside .ancestor-public-test. Because the component declares the public pair on :host, the ancestor's inherited public values are not expected to win.Ancestor value, host public value, internal alias, rendered value.The case is documented as a supported override, or the observed result is accepted without reconciling the contract.
Nested componentInspect the chip inside status-cluster; the cluster's semantic pair should cross the nested boundary.Cluster host, nested chip host, nested .chip, rendered pair.The value disappears at a boundary or changes an unrelated outer chip.
Dark modeChange the containing data-theme value to dark; stable semantic names should resolve to the dark pair.Mode selector, semantic source pair, host pair, internal pair, rendered pair.Names change, one side remains light, or the component takes ownership of application mode selection.
Missing public mappingRemove the :host public declaration and its upstream source in an isolated variant; the private alias fallback should supply its documented value.Host public value, internal alias, rendered pair.No value reaches the consumer or the result differs from the documented fallback.
Missing upstream sourceKeep the :host mapping but omit the application semantic property; the nested var() fallback should resolve on the host.Emitted stylesheet absence, host public value, internal value.A stale or accidental ancestor value masks the missing source.
Invalid public valueSet --chip-background to not-a-color directly on the host. Record the custom-property text without predicting a valid rendered color.Host text, internal text, computed background-color, test output.The case is accepted without recording the consuming property's result and the product policy.
Fallback chainTest source present, source absent, and public mapping absent as separate cases.Every var() input and the final rendered property.A fallback is credited without proving which earlier value was unavailable.
Private-alias declarationDeclare --_chip-bg outside the component; the declaration on .chip should remain the local source in this fixture.External private-name value, .chip private-name value, rendered background.The external declaration becomes a supported dependency or changes the consumer unexpectedly.
Protected surfaceDeclare an undocumented --chip-radius on the host; border-radius should remain the internal 999px rule.Host declaration and computed border-radius on .chip.An undocumented hook changes the protected rule or is adopted without a contract decision.
Controlled test matrix. Every row starts in expected, unexecuted status.

An invalid custom-property value needs a separate case. Text such as not-a-color can remain visible as the computed value of a custom property, then fail when substituted into background-color. A var() fallback doesn't validate arbitrary color text. Observe the consuming property first. Then decide whether the component should constrain, reject, document, or tolerate the input.

Inspect every hop, not only the screenshot

Start with the emitted stylesheet. Confirm that the identified artifact contains the expected semantic names and mode values, then record its version, build identifier, content hash, or another stable identity. A correct source file doesn't prove the application loaded the same artifact.

  1. 1

    Inspect the emitted property

    Locate the exact semantic name for each mode in the delivered stylesheet or built CSS. Record the artifact identity and selector. If the property is absent here, stop before blaming the component.

  2. 2

    Inspect the component host

    Read the upstream semantic property and documented public property on the same host. This separates delivery and cascade behavior from the shadow-tree implementation.

    const host = document.querySelector("#default-chip");
    const read = (element, name) =>
      getComputedStyle(element).getPropertyValue(name).trim();
    
    console.table({
      sourceBackground: read(host, "--app-status-success-bg"),
      sourceForeground: read(host, "--app-status-success-fg"),
      publicBackground: read(host, "--chip-background"),
      publicForeground: read(host, "--chip-foreground")
    });
  3. 3

    Inspect the internal alias

    For this open-shadow-root fixture, read the private aliases from the named internal consumer. If a production component has a closed shadow root, collect the evidence inside the component repository instead of weakening encapsulation for outside inspection.

    const chip = host.shadowRoot.querySelector(
      '[data-consumer="chip"]'
    );
    
    console.table({
      privateBackground: read(chip, "--_chip-bg"),
      privateForeground: read(chip, "--_chip-fg")
    });
  4. 4

    Inspect the rendered consumer

    Read the CSS properties that consume the aliases. Record or normalize the browser's serialization instead of comparing it blindly with a hex literal.

    const rendered = getComputedStyle(chip);
    
    console.table({
      renderedBackground: rendered.backgroundColor,
      renderedForeground: rendered.color,
      renderedRadius: rendered.borderRadius
    });
  5. 5

    Compare ancestor and host-matching overrides

    Read the public property on both hosts. The ancestor-only case tests inheritance; the matched-host case tests the documented component API.

    const ancestorCase = document.querySelector(
      "#ancestor-public-chip"
    );
    const matchedHostCase = document.querySelector(
      "#matched-host-chip"
    );
    
    console.table({
      ancestorPublic: read(
        ancestorCase,
        "--chip-background"
      ),
      matchedHostPublic: read(
        matchedHostCase,
        "--chip-background"
      )
    });
  6. 6

    Inspect the nested path

    Resolve the nested chip through both open shadow roots, then repeat the host, alias, and rendered checks. The observation applies only to the recorded fixture and condition.

    const cluster = document.querySelector("#nested-cluster");
    const nestedHost = cluster.shadowRoot.querySelector("#nested-chip");
    const nestedConsumer = nestedHost.shadowRoot.querySelector(
      '[data-consumer="chip"]'
    );
    
    console.table({
      nestedSource: read(nestedHost, "--app-status-success-bg"),
      nestedPublic: read(nestedHost, "--chip-background"),
      nestedPrivate: read(nestedConsumer, "--_chip-bg"),
      nestedRendered: getComputedStyle(nestedConsumer).backgroundColor
    });

A screenshot is downstream evidence

A screenshot can show that a surface looked right under one condition. It can't identify the source artifact, the declaration that won the cascade, or whether the implementation used a private dependency. Keep the visual evidence, but pair it with the computed-value trace.

Start from named semantic roles

A Web Component contract is easier to maintain when its defaults map to stable semantic roles instead of raw colors. Inspect an existing token system before defining the host-facing properties.

Classify each styling surface by consumer need

Use it whenContract consequenceDo not assume
Public component propertyA consumer needs to change one documented component decision.The name, semantics, host scope, modes, states, and compatibility policy belong to the component API.Every internal value should be exposed.
Private aliasThe implementation needs a readable local mapping or fallback chain.The component may rename or remove it without consumer migration support.An underscore prevents external declarations.
Accidentally inherited propertyNo owner has intentionally classified the dependency.Block new adoption until it is made public, made private, or removed.Current inheritance already creates a compatibility promise.
CSS partA consumer needs controlled selector-based access to a named internal element.The part name and permitted styling scope become documented customization surfaces.A part is interchangeable with a semantic token.
Inherited ordinary propertyNormal CSS inheritance, such as text color, is intentionally part of composition.Document the supported context and any resets or exceptions.Every ordinary property inherits.
Hard-coded internal ruleThe component owns a protected decision with no current consumer need for variation.Changes stay with the component implementation and its regression evidence.Consumers can or should override it.
Use the narrowest supported surface that matches the consumer's need.

DÁP publishes component custom properties and CSS parts instead of asking consumers to discover internals. Another system may choose different surfaces. What matters is whether each surface is deliberately supported.

Keep mode ownership outside the component

Stable semantic names let light and dark values change without making the component choose the theme. In the fixture, the application changes values behind --app-status-success-bg and --app-status-success-fg. The component keeps mapping them into --chip-background and --chip-foreground.

The consuming application still owns preference detection, persistence, selector strategy, stylesheet loading, and import order. A component can support both modes while staying neutral about how the application selects one. Record nested mode scopes separately because a local theme container may legitimately differ from the document.

Test foreground and background together in every supported state. Checking only the background may leave content on the wrong mode value. This procedure verifies delivery and consumption, but it doesn't establish an accessibility result by itself.

Copy the acceptance record and fill observations last

Create one record per condition when values or ownership differ. This example is intentionally incomplete. Its disposition remains block because no runtime observation has been recorded.

source:
  authority: "Application design-system owner"
  version: "REPLACE_WITH_SOURCE_VERSION"
  semantic_role: "success status background and foreground"

artifact:
  identity: "REPLACE_WITH_STYLESHEET_BUILD_OR_HASH"
  selector: ":root and recorded mode selector"
  emitted_background: "--app-status-success-bg"
  emitted_foreground: "--app-status-success-fg"

component:
  name: "my-status-chip"
  version: "REPLACE_WITH_COMPONENT_VERSION"
  public_background: "--chip-background"
  public_foreground: "--chip-foreground"
  supported_override_scope: "selector matching component host directly"
  inherited_public_override_supported: false
  private_background: "--_chip-bg"
  private_foreground: "--_chip-fg"
  consumer: ".chip"
  consumer_properties:
    - "background-color"
    - "color"

test:
  condition: "document default, light mode, success state"
  browser_and_os: "RECORD_AT_EXECUTION"
  expected_host_background: "#e8f5ec"
  expected_host_foreground: "#184c2f"
  expected_internal_background: "#e8f5ec"
  expected_internal_foreground: "#184c2f"
  expected_rendered_state: "success pair on .chip"

observation:
  emitted_values: "NOT_RECORDED"
  host_background: "NOT_RECORDED"
  host_foreground: "NOT_RECORDED"
  internal_background: "NOT_RECORDED"
  internal_foreground: "NOT_RECORDED"
  rendered_background: "NOT_RECORDED"
  rendered_foreground: "NOT_RECORDED"
  protected_surface_result: "NOT_RECORDED"
  evidence_reference: "NOT_RECORDED"

exception:
  status: "none proposed"
  rationale: ""
  expiry_or_review_trigger: ""

correction:
  suspected_layer: "unassigned"
  owner: "unassigned until evidence identifies the layer"
  next_inspection: "run the recorded condition"

disposition:
  value: "block"
  reason: "Expected values exist, but runtime observations are absent."
Copyable acceptance record. Keep the disposition blocked until observations and an evidence reference are recorded.

Use accept when the identified artifact and representative condition match the contract and protected surfaces remain unchanged. Use revise when the intended contract remains valid but needs a bounded correction or documented exception. Use block when required evidence is absent, authority is unresolved, an unsupported dependency is being adopted, or the result contradicts a release requirement.

Route failures from evidence, not appearance

A browser mismatch doesn't automatically belong to delivery, and a difference from a design file doesn't automatically belong to transformation. Follow the trace until you find the first recorded divergence. Only then should you assign the correction.

Next inspectionLayer that may own correctionEvidence required before assignment
Source role or mode value is absentOpen the versioned upstream source and confirm the approved role.Upstream source owner.Source identity, expected role, approval state, and actual source value.
Source is correct but emitted property is absent or staleInspect transformation configuration and generated stylesheet identity.Transformation or build owner.Matching source version, build input, configuration, and emitted artifact.
Artifact is correct but the host lacks the semantic valueConfirm stylesheet loading, selector match, mode scope, and import order.Delivery or application owner.Loaded artifact identity and computed semantic value on the host.
Host semantic value is correct but public property differsInspect direct host rules, matched external selectors, :host mappings, and precedence.Application cascade or component-contract owner.Matched declarations and computed values on the same host.
Ancestor public declaration does not overrideCheck whether the public property was declared only on an ancestor rather than by a selector matching the host.Consumer implementation owner unless inherited overrides are added to the contract.Ancestor declaration, host declaration, computed host value, and documented override scope.
Public property is correct but private alias differsInspect the internal alias declaration and active state selector.Component implementation owner.Component version, active state, public value, and internal computed value.
Private alias is correct but rendered property differsInspect the consuming declaration, competing internal rules, and value validity.Component consumer implementation owner.Alias value, matched rule, rendered property, and active state.
Nested component alone differsTrace the value at the outer host, nested host, and nested consumer.Nested scope, component mapping, or application composition owner.Separate computed values at every boundary.
External private alias changes the resultCheck where the component declares the alias and whether anyone documented the external dependency.Component owner for implementation; consumer owner for unsupported use.Matched declarations and the contract classification.
Protected surface changesIdentify the selector, part, inherited value, or custom property that reached it.Component-contract owner after reachability is confirmed.Reproduction, matched rule, documented API list, and affected consumers.
Evidence-led failure routing

Where Identity Forge stops

Identity Forge can provide upstream semantic decisions and CSS or DTCG token exports. Those artifacts can supply the application side of this chain. They don't define a project's Web Components, select its public styling properties, control its theme selector or import order, or prove what a browser rendered.

The consuming project owns the mapping from exported roles to runtime properties, along with the Web Component styling API, internal aliases, selectors, fallbacks, compatibility policy, fixtures, and acceptance evidence. Calling an artifact a DTCG export also doesn't establish compatibility with every transformer or consumer.

Make one inherited property intentional

Choose one custom property that currently reaches a shadow tree. Classify it as public, private, or accidental. If it's public, document the selector scope that may override it and the compatibility policy. If it's private, remove consumer reliance and declare it beside the internal element where practical. If it's accidental, block new adoption until an owner decides its status.

Next, run one recorded condition covering a mode, state, nested scope, host-matching override, ancestor-only declaration, and protected surface. Fill the observation fields and evidence reference before changing the disposition from block. Finish that record before another component or application adopts the name.

Sources