Elevation & Shadow Tokens: Architectural Foundations for Depth Management

Part of Token Fundamentals & Naming Conventions. Elevation and shadow tokens solve the specific problem of codifying visual depth — abstracting CSS box-shadow and filter: drop-shadow() declarations into semantic, reusable values that survive theme migrations and scale across every surface in a component library.

Elevation scale: five depth levels with growing shadow Five stacked card shapes showing increasing shadow blur and offset from Level 0 (flat) through Level 4 (overlay), annotated with CSS token names and violet accent labels. Elevation Scale — Primitive to Semantic Token Mapping Level 0 Level 1 Level 2 Level 3 Level 4 Flat surface Raised card Sticky header Dropdown / popover Modal / dialog Increasing depth --elevation-flat 0px 0px 0px rgba(0,0,0,0) --elevation-surface 0 2px 8px rgba(0,0,0,.12) --elevation-raised 0 4px 16px rgba(0,0,0,.15) --elevation-overlay 0 8px 24px rgba(0,0,0,.20) --elevation-modal 0 12px 40px rgba(0,0,0,.28)
Five-level elevation scale mapping visual depth to semantic CSS custom property names, from a flat surface token through modal-level shadow intensity.

Problem Framing

Component libraries that skip a formal elevation model accumulate hardcoded box-shadow values scattered across dozens of component files. When a design decision changes — the standard card shadow needs 2px less blur across 400 components — there is no single source of truth to update. The same entropy hits dark mode: shadows invisible against dark backgrounds require a completely different value set, but with no token abstraction the change cascades into every component’s CSS. The elevation token layer exists precisely to prevent both failure modes.

Three-Tier Architectural Trade-offs

A production depth architecture separates primitive tokens (raw CSS values) from semantic tokens (contextual usage) from component-local overrides (one-off adjustments).

  • Primitive vs. semantic coupling: Hardcoding primitives directly into components offers faster initial development but creates maintenance debt during theme migrations. Semantic abstraction requires upfront registry configuration but enables zero-touch theme switching.
  • box-shadow vs. filter: drop-shadow(): box-shadow performs optimally for rectangular surfaces and is rendered on the compositor in most browsers. drop-shadow() respects alpha channels and complex paths (clipped SVG, arbitrary polygon) but triggers a more expensive paint operation. Use box-shadow for 95% of UI surfaces and reserve filter: drop-shadow() for iconography and SVG overlays.
  • Z-index isolation vs. elevation coupling: Elevation tokens must not carry embedded z-index values. Couple them conceptually — Level 3 elevation implies z-index: 300 — but store those as separate tokens. CSS stacking contexts (isolation: isolate) contain depth layers and prevent global z-index conflicts; elevation tokens should not attempt to encode that containment.
  • Static vs. dynamic shadow composition: A single box-shadow string token (--elevation-surface: 0px 2px 8px rgba(0,0,0,.12)) is simple to consume but impossible to manipulate at runtime. Decomposing into --shadow-offset-y, --shadow-blur, and --shadow-color primitive parts allows dark-mode opacity shifts without duplicating all values — at the cost of a more complex consumer API.
  • Per-token vs. computed fallback chains: Wrapping every elevation reference in a computed fallback (var(--elevation-surface, 0 2px 8px rgba(0,0,0,.12))) protects against token-load failures in SSR but means the fallback drifts when the canonical token updates.

Build Pipeline: Design Export to Compiled CSS

  1. Design tool export — Shadow styles in Figma are exported via the Tokens Studio plugin as JSON, using the DTCG $type: shadow schema. Each entry maps a semantic name to offset, blur, spread, and color fields.
  2. Primitive definition — Base values (--shadow-blur-sm: 8px, --shadow-color-ambient: rgba(0,0,0,0.12)) are authored in the token registry as platform-agnostic JSON before semantic tokens reference them. See tokenizing elevation values for consistent depth for the canonical decomposition strategy.
  3. Style Dictionary compilation — The transform pipeline resolves primitive references and outputs a single elevation.css file with :root-scoped custom properties. A custom formatter flattens multi-part shadow objects into valid box-shadow strings.
  4. Semantic mapping — A second transformation pass maps primitive tokens to semantic names (--elevation-surface{shadow.sm.offset-y} {shadow.sm.blur} ...). This keeps the primitive tier editable without touching semantic names.
  5. CSS variable injection — Compiled output is imported once in the application entry point (or via @import in the design system’s root stylesheet). Shadow DOM consumers repeat the :root block as :host.
  6. Stylelint enforcement — A pre-commit hook runs Stylelint with declaration-property-value-disallowed-list to reject any raw box-shadow value that bypasses the token registry.
  7. Visual regression snapshot — Chromatic or Percy captures baseline renders of elevation-dependent components (cards, dropdowns, modals). Any diff above the configured threshold blocks the PR.
  8. Release and changelog — Semantic Release bumps the package version on any token value change. Downstream consumers receive a semver signal indicating whether the elevation change is a patch (visual tweak) or minor (new token added).

Validation & Quality Gates

CI/CD Pipeline Configuration

name: Token Validation & Shadow Audit
on:
  pull_request:
    paths: ['tokens/**', 'styles/**']

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node
        uses: actions/setup-node@v4
        with: { node-version: '22' }
      - name: Install Dependencies
        run: npm ci
      - name: Lint Tokens & CSS
        run: npm run lint:tokens && npm run lint:css
      - name: Run Visual Regression
        uses: chromaui/action@v1
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          buildScriptName: 'chromatic:build'

Stylelint Configuration for Shadow Enforcement

Hardcoded shadows bypass the token registry and break theme consistency. The following .stylelintrc.json rejects raw box-shadow values in component CSS:

{
  "rules": {
    "declaration-property-value-disallowed-list": {
      "box-shadow": ["/^\\d/", "/^rgba/", "/^rgb/"],
      "filter": ["/^drop-shadow/"]
    },
    "custom-property-pattern": "^--(elevation|shadow)-[a-z0-9-]+$"
  }
}

Any box-shadow value not going through a var() reference fails the lint step. During a migration phase, add a /* stylelint-disable */ comment with a TODO(token-migration): annotation so the escape is tracked and finite.

Validation Tool Table

Tool Purpose Integration Point
Style Dictionary Compiles JSON token definitions to CSS, SCSS, JS Pre-build step in CI and local dev
Stylelint + declaration-property-value-disallowed-list Rejects hardcoded shadow values Pre-commit hook and PR check
Chromatic / Percy Visual regression on elevation-dependent components PR merge gate
Token Lint (token-validator or custom script) Schema validation — ensures every semantic token resolves to a primitive Post-compile CI step
Lighthouse CI Detects paint regressions caused by heavy filter or blur values Nightly performance build

Pipeline trade-offs:

  • Strict vs. lenient linting: Enforcing zero hardcoded shadows on a legacy codebase causes immediate CI failures across the board. Gate the strict rule to new files only via overrides.files in Stylelint config, then schedule a codemod sprint.
  • Visual regression overhead: Scoping Chromatic to elevation-dependent components only (cards, dropdowns, modals) cuts snapshot count by 60–80% without sacrificing coverage of the highest-risk surfaces.

Cross-Cluster Dependency Table

Parent Pillar Sibling Domain Integration Point Validation Strategy
Token Fundamentals & Naming Conventions Spacing & Layout Tokens Shadow offset-y values must scale proportionally with spacing primitives (e.g., Level 1 shadow uses the same 4px base as --space-1) Assert shadow.sm.offset-y === space.1.value in a token integrity test
Token Fundamentals & Naming Conventions Color Palette Architecture Shadow color tokens reference the color primitive tier (--color-shadow-ambient) rather than raw RGBA strings Stylelint custom-property-pattern rejects raw rgba() in shadow declarations
Token Fundamentals & Naming Conventions Typography Scale Systems Type-scale changes at higher levels (display, headline) correlate with elevated containers — token documentation must cross-reference expected elevation levels for those containers Manual design review checklist in PR template
Token Fundamentals & Naming Conventions Houdini @property Type-Safe Tokens @property --elevation-surface { syntax: '<shadow>'; } registration enables type checking and CSS transitions without JavaScript Test @supports (syntax: '<shadow>') in target browsers before enabling

CSS Dependency Annotation Example

/* @depends: --color-shadow-ambient (color-palette-architecture)
   @depends: --space-1 (spacing-layout-tokens)
   Ensure both tokens are resolved before this file is imported. */

:root {
  --elevation-surface: 0px var(--space-1, 4px) 8px 0px var(--color-shadow-ambient, rgba(0, 0, 0, 0.12));
  --elevation-raised:  0px calc(var(--space-1, 4px) * 2) 16px 0px var(--color-shadow-ambient, rgba(0, 0, 0, 0.12));
  --elevation-overlay: 0px calc(var(--space-1, 4px) * 4) 28px 0px var(--color-shadow-ambient, rgba(0, 0, 0, 0.20));
  --elevation-modal:   0px calc(var(--space-1, 4px) * 6) 40px 0px var(--color-shadow-ambient, rgba(0, 0, 0, 0.28));
}

Production Code Reference

Framework-Agnostic Token Definition (Style Dictionary)

{
  "elevation": {
    "primitive": {
      "offset-y-sm": { "value": "2px" },
      "blur-sm": { "value": "8px" },
      "spread": { "value": "0px" },
      "color-light": { "value": "rgba(0, 0, 0, 0.12)" }
    },
    "semantic": {
      "surface": {
        "value": "0px 2px 8px 0px rgba(0, 0, 0, 0.12)"
      },
      "raised": {
        "value": "0px 4px 12px 0px rgba(0, 0, 0, 0.15)"
      },
      "overlay": {
        "value": "0px 12px 32px 0px rgba(0, 0, 0, 0.25)"
      }
    }
  }
}

Compiled into CSS custom properties, this enables framework-agnostic consumption from a single source of truth:

:root {
  --elevation-surface: 0px 2px 8px 0px rgba(0, 0, 0, 0.12);
  --elevation-raised: 0px 4px 12px 0px rgba(0, 0, 0, 0.15);
  --elevation-overlay: 0px 12px 32px 0px rgba(0, 0, 0, 0.25);
}

.card {
  box-shadow: var(--elevation-surface);
  transition: box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

Decouple token generation from framework-specific bundlers. A standalone compiler (Style Dictionary) that outputs CSS variables, SCSS maps, and JS objects simultaneously ensures React, Vue, and vanilla implementations consume identical depth values without duplication.

Dynamic Theme Adaptation

Dark mode elevation requires a perceptual shift: shadows become less visible on dark backgrounds, requiring either increased opacity, added ambient light layers, or border-based depth cues. Decoupling shadow opacity from the value string makes this possible without duplicating the entire elevation tier:

:root {
  --elevation-color: rgba(0, 0, 0, 0.15);
  --elevation-border: none;
  --elevation-surface: 0px 2px 8px 0px var(--elevation-color);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Shift to subtle ambient glow + border for WCAG contrast compliance */
    --elevation-color: rgba(255, 255, 255, 0.08);
    --elevation-border: 1px solid rgba(255, 255, 255, 0.12);
  }
}

[data-theme="dark"] {
  --elevation-color: rgba(255, 255, 255, 0.08);
  --elevation-border: 1px solid rgba(255, 255, 255, 0.12);
}

.surface {
  box-shadow: var(--elevation-surface);
  border: var(--elevation-border, none);
  background: var(--color-surface);
}

High-contrast mode: Windows and macOS HCM strips shadows entirely. Use @media (forced-colors: active) to replace box-shadow with outline or border, ensuring WCAG 2.2 compliance. The color palette architecture section covers the ButtonText and ButtonFace system color replacements that apply in the same media context.

Performance boundaries: Excessive blur radii trigger software rasterization on some hardware. Cap blur at 32px and use will-change: box-shadow sparingly on interactive elements.

Diagnostic Matrix

Diagnostic Step Execution Detail
Verify token resolution Open DevTools → Computed → check that box-shadow resolves to a value, not initial. An unresolved var() silently produces initial, leaving the element flat.
Check cascade order Inspect Styles panel for overrides. A component-level box-shadow: none overriding the token is the most common cause of “missing shadow” bugs.
Confirm dark-mode token swap Toggle [data-theme="dark"] in DevTools Elements panel and watch the --elevation-color custom property update in the Computed tab.
Audit Stylelint output Run npx stylelint "src/**/*.css" --formatter verbose and look for declaration-property-value-disallowed-list violations — these are hardcoded shadows that bypassed the registry.
Check HCM rendering Open Edge → Settings → Accessibility → High contrast → force active. Confirm shadows are replaced by borders/outlines.
Profile paint cost DevTools Performance tab → record scroll → look for Paint events over 16ms. Heavy filter: drop-shadow() on scrolling containers is the primary cause.

Common Root Causes & Resolutions

Symptom Root Cause Resolution
Shadow appears on light mode, missing on dark --elevation-color not overridden in [data-theme="dark"] block Add the dark-theme :root [data-theme="dark"] override; also add @media (prefers-color-scheme: dark) fallback
Stylelint passes but hardcoded shadow renders Custom property name does not match the regex in custom-property-pattern Audit token names against ^--(elevation|shadow)-[a-z0-9-]+$; rename out-of-pattern tokens
Transition jank during hover elevation change will-change: box-shadow missing, or blur radius triggers software render Add will-change: box-shadow to interactive elements; ensure blur ≤ 32px
Shadow clipped at container boundary Parent element has overflow: hidden or a stacking context cropping the shadow Apply overflow: visible on the parent or move the shadow to an ::after pseudo-element outside the clipping context
Token compiles but shadow values differ between themes Primitive token value changed without updating the semantic layer that references it Use Style Dictionary’s transitive transforms so semantic tokens automatically recompile when their referenced primitives change

Frequently Asked Questions

Should elevation tokens store the full box-shadow string or individual parts?

Both strategies are valid; choose based on runtime flexibility needs. A single composite string (--elevation-surface: 0px 2px 8px rgba(0,0,0,.12)) is simpler to consume and results in fewer custom properties. Decomposed parts (--shadow-offset-y, --shadow-blur, --shadow-color) enable programmatic manipulation at runtime — useful if your theming system needs to vary opacity or blur independently per theme. For most design systems, composite strings at the semantic tier with decomposed primitives underneath is the sweet spot: semantics are consumed in components, primitives are only referenced during token compilation.

How many elevation levels does a design system actually need?

Five levels (flat, surface, raised, overlay, modal) covers the overwhelming majority of UI patterns in enterprise applications. Adding a sixth or seventh level creates ambiguity — engineers start making subjective calls about which level applies, and the system loses its predictive power. Start with five. If a specific component genuinely needs a value outside the scale, create a component-scoped override token (e.g., --card-hero-shadow) rather than expanding the global elevation scale.

How do elevation tokens interact with z-index management?

They should be correlated but independent. Document the expected pairing (Level 3 elevation → z-index: 300 range) in your token registry’s comments or companion documentation, but store them as separate tokens. Embedding z-index values inside shadow tokens is a category error: shadows are visual properties, stacking order is layout logic. Keeping them separate also allows a low-elevation element to sit above a high-elevation one when stacking context isolation is needed — which happens routinely in complex layouts.