How to map design tokens to cascade layers

Design tokens declared outside any @layer block silently win over all your named layers — the symptom is a component whose background or color ignores every theme switch you try. This guide is a concrete procedure for binding each token category to the right @layer block so the cascade resolves predictably. It sits within the Theme & Token Layer Mapping cluster, which is part of the Architecture Patterns & Design System Scaling section of this site.

Prerequisites

You should already understand how @layer declaration order controls cascade priority — review understanding layer declaration order if that is unclear. You will also need a build pipeline that emits a single entry-point CSS file (PostCSS + postcss-import, Vite, or equivalent) and optionally @csstools/stylelint-plugin-cascade-layers for CI enforcement.


Token-to-layer mapping at a glance

The diagram below shows how the canonical six-layer stack maps to token categories. Arrows indicate consumption direction: a higher layer may read a lower layer’s custom properties via var(), but never the reverse.

Token-to-layer mapping: six cascade layers and their token categories A stacked diagram of six cascade layers from bottom (reset) to top (overrides). Each layer box shows the layer name on the left and the token category it holds on the right. Arrows on the right side indicate that higher layers consume tokens from lower layers via var(). overrides Page-level exceptions, emergency patches utilities Single-purpose helper classes (.mt-4, .text-sm) components Component variables, layout rules (consume theme) theme Semantic tokens (--color-primary, --radius-default) base Primitive tokens (--color-blue-500, --spacing-2) reset Browser normalisation, box-model resets lower → higher priority var() var()

Step 1 — Audit existing token declarations

What this does: locates every CSS custom property that sits outside a named @layer block. These unlayered declarations win over all your named-layer tokens for any matching property, no matter how carefully you order the rest of the stack.

Open Chrome or Firefox DevTools → ElementsStyles panel and inspect :root. Any --color-*, --spacing-*, or similar rule displayed without a @layer badge is unlayered and will override your layered tokens.

Mechanically, run a grep across your source tree:

/* grep -rn "^:root\|^\s*--" src/ | grep -v "@layer"
   Any hit that is not inside a @layer block is a candidate for migration */

Categorise each token into one of these buckets before touching any code:

  • Primitive — raw scale values (--color-blue-500: #3b82f6, --spacing-2: 8px). Target layer: base.
  • Semantic — contextual aliases (--color-primary, --radius-default). Target layer: theme.
  • Component-scoped — variables that only apply inside one component (--card-padding). Target layer: components, declared on the component selector, not :root.

Step 2 — Declare explicit layer order

What this does: tells the browser the complete layer stack before it encounters any rules. The first @layer statement the browser sees locks in the priority order; any later implicit creation causes unpredictable token resolution.

Place this at the absolute top of your entry stylesheet — before every @import and before every rule block:

/* Entry stylesheet: tokens.css or main.css
   Declare ALL layers upfront so implicit creation never happens.
   Order = ascending priority: later layers beat earlier layers. */
@layer reset, base, theme, components, utilities, overrides;

If you use @import to pull in token files, the import must come after this declaration line:

/* This declaration must appear before any @import that targets a layer */
@layer reset, base, theme, components, utilities, overrides;

/* Now assign each imported file to its layer */
@import "tokens/primitives.css" layer(base);    /* raw scale values */
@import "tokens/semantic.css"   layer(theme);   /* contextual aliases */
@import "components/index.css"  layer(components);

Step 3 — Assign token categories to their layers

What this does: places each token group inside the correct @layer block so the cascade resolves them in the order you declared.

/* primitives.css — consumed by @import … layer(base) above */
@layer base {
  :root {
    /* Raw scale values — no references to other custom properties here.
       These are the ground truth; every semantic token derives from these. */
    --color-blue-500:  #3b82f6;
    --color-gray-50:   #f8f9fa;
    --color-gray-900:  #111827;
    --spacing-2:       0.5rem;
    --spacing-4:       1rem;
    --radius-sm:       4px;
  }
}

/* semantic.css — consumed by @import … layer(theme) above */
@layer theme {
  :root {
    /* Semantic tokens alias base primitives.
       Because @layer theme is declared after @layer base, a :root rule
       in theme wins over a :root rule in base for the same custom property. */
    --color-primary:   var(--color-blue-500, #0d6efd);
    --color-surface:   var(--color-gray-50, #f8f9fa);
    --color-text:      var(--color-gray-900, #111827);
    --radius-default:  var(--radius-sm, 4px);
    --space-block:     var(--spacing-4, 1rem);
  }

  /* Dark theme variant: toggled by a data attribute on <html>.
     No JavaScript specificity tricks needed — the cascade handles it. */
  [data-theme="dark"] {
    --color-surface:   #121212;
    --color-text:      #f5f5f5;
  }
}

/* components/card.css — consumed by @import … layer(components) */
@layer components {
  /* Component tokens live on the component selector, not :root,
     so they cannot leak globally when reused in different contexts. */
  .card {
    --card-padding:    var(--space-block);
    --card-bg:         var(--color-surface);
    --card-border:     1px solid currentColor;

    background:        var(--card-bg);
    border:            var(--card-border);
    border-radius:     var(--radius-default);
    padding:           var(--card-padding);
    color:             var(--color-text);
  }
}

The key constraint: consumption flows downward only. A components rule may reference --color-surface from theme; a theme rule must never reference a components variable.


Step 4 — Write layer-aware fallback chains

What this does: protects against the token being undefined at parse time (e.g., a polyfill has not yet injected the base layer) by ending every var() chain with a hardcoded primitive.

@layer components {
  .alert {
    /* Three-level fallback chain:
       1. Try the component-specific override token.
       2. Fall back to the semantic surface colour from @layer theme.
       3. Final hardcoded primitive — never another custom property. */
    background-color: var(--alert-bg, var(--color-surface, #ffffff));

    /* Same pattern for typography */
    color: var(--alert-text, var(--color-text, #111827));

    /* Border uses the semantic token with a direct hardcode as guard */
    border: 1px solid var(--color-primary, #3b82f6);
  }
}

Never use another custom property as the final fallback in a chain — circular or mutually undefined references produce invisible failures with no browser warning.


Step 5 — Verify token resolution

What this does: confirms the cascade resolves tokens from the layer you intended, not from a stale unlayered rule.

DevTools trace

  1. In Chrome DevTools, open Elements → Computed and search for --color-surface.
  2. Click the arrow next to the resolved value to jump to the origin rule.
  3. The Styles panel shows the @layer badge next to the winning rule. If the badge is absent, the rule is unlayered and will beat your entire stack.
  4. If you see @layer base winning instead of @layer theme, the declaration order in your entry stylesheet is wrong — theme must appear after base in the @layer declaration list.

Stylelint enforcement

Add @csstools/stylelint-plugin-cascade-layers to your CI pipeline to catch undeclared layer usages before they reach production:

{
  "plugins": ["@csstools/stylelint-plugin-cascade-layers"],
  "rules": {
    "csstools/cascade-layers": [true, { "layersOrder": ["reset", "base", "theme", "components", "utilities", "overrides"] }]
  }
}

Run npx stylelint 'src/**/*.css' — any custom property declared outside a layer triggers a lint error.


Troubleshooting

Unlayered :root rule beats layered theme tokens : Any --color-* or --spacing-* declaration outside a @layer block sits in the implicit unlayered tier, which beats all named layers. Find the declaration with DevTools (look for the absence of a @layer badge), then wrap it in the appropriate @layer block.

@layer theme wins over @layer components : Your layer declaration order has theme after components. The cascade assigns higher priority to the later-declared layer. Check your entry-stylesheet @layer declaration line and move components to the right of theme.

Dark-mode tokens not activating : If [data-theme="dark"] tokens are in @layer theme but a [data-theme="dark"] rule also exists outside any layer (e.g., injected by a JavaScript framework), the unlayered rule wins. Move or wrap the conflicting rule in @layer theme.

var() resolves to empty in a component : The consuming component is in @layer components but the token it references was never assigned to any layer, so it is undefined. Trace the token origin in the Computed panel and add its declaration to @layer base or @layer theme.

Build tool reorders @layer declarations : Some older PostCSS configurations hoist @import statements above @layer declarations. Use postcss-import in combination with an explicit @layer declaration before the first @import, and verify the compiled output preserves that order with grep -n "@layer" dist/styles.css.


Complete working example

Copy-paste this self-contained stylesheet to validate the full flow in isolation. Open in a browser, toggle data-theme="dark" via DevTools to confirm the theme layer activates correctly without touching the components layer.

/* === Entry point: design-system.css === */

/* Step 2: Lock in layer priority before anything else loads.
   Later in this list = higher priority. */
@layer reset, base, theme, components, utilities, overrides;

/* ── reset ─────────────────────────────────────────── */
@layer reset {
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { line-height: 1.5; }
}

/* ── base: primitive tokens ─────────────────────────── */
@layer base {
  :root {
    /* Immutable scale values — never aliases, always final values */
    --color-blue-500:  #3b82f6;
    --color-gray-50:   #f8f9fa;
    --color-gray-900:  #111827;
    --spacing-2:       0.5rem;
    --spacing-4:       1rem;
    --radius-sm:       4px;
  }
}

/* ── theme: semantic tokens ─────────────────────────── */
@layer theme {
  :root {
    /* Alias primitives; fallback to hardcoded value so polyfill gap is safe */
    --color-primary: var(--color-blue-500, #3b82f6);
    --color-surface: var(--color-gray-50, #f8f9fa);
    --color-text:    var(--color-gray-900, #111827);
    --radius-card:   var(--radius-sm, 4px);
  }

  /* Dark mode: data attribute toggled from JavaScript — no !important needed */
  [data-theme="dark"] {
    --color-surface: #1a1a2e;
    --color-text:    #e2e8f0;
  }
}

/* ── components ─────────────────────────────────────── */
@layer components {
  /* Component-scoped token lives on the selector, not :root */
  .card {
    --card-bg:      var(--color-surface);
    --card-text:    var(--color-text);

    background:     var(--card-bg, #fff);
    color:          var(--card-text, #111);
    border:         1px solid var(--color-primary, #3b82f6);
    border-radius:  var(--radius-card, 4px);
    padding:        var(--spacing-4, 1rem);
    max-width:      24rem;
  }
}

/* ── utilities ──────────────────────────────────────── */
@layer utilities {
  /* Single-purpose helpers — declared after components so they can override
     layout properties (e.g., .mt-0) without beating component styling via
     specificity games. */
  .mt-4   { margin-top:    var(--spacing-4, 1rem); }
  .sr-only {
    position: absolute; width: 1px; height: 1px;
    padding: 0; overflow: hidden; clip: rect(0,0,0,0);
    white-space: nowrap; border: 0;
  }
}

/* ── overrides ──────────────────────────────────────── */
@layer overrides {
  /* Emergency or page-specific patches go here.
     Being in the highest layer means no specificity inflation is needed. */
}

Frequently asked questions

Why do my layered theme tokens lose to a plain :root rule I didn't write?

Any custom property declared outside an @layer block — including those injected by third-party analytics or design-tool exports — is unlayered. Unlayered author styles sit above all named layers in the cascade, regardless of specificity or source order. Open DevTools Styles panel and look for --token-name rules that lack a @layer badge. Wrap them in the correct @layer block, or if they come from an external file you cannot edit, wrap the @import in a layer: @import "vendor.css" layer(theme).

Can I generate layered token CSS from a Style Dictionary or Theo build?

Yes. Configure Style Dictionary’s CSS formatter to emit output wrapped in @layer base { :root { … } } and @layer theme { :root { … } } blocks. The generated file must be imported after the @layer stack declaration line in your entry stylesheet. If the generated file is imported first (or if your bundler hoists the import), the browser creates an implicit layer order that ignores your explicit declaration. Verify the compiled output with grep -n "@layer" dist/styles.css to confirm ordering.

What happens if a token is consumed before its layer has been parsed?

The browser treats the custom property as undefined and applies the var() fallback value. If no fallback exists, the property is silently ignored — no console error, no visible warning. This is why every var() chain should end with a hardcoded primitive. Declaring the full @layer stack at the very top of the entry stylesheet ensures the browser registers all layer names before it evaluates any token references, so the resolution order is always stable.