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 Link to this section

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 Link to this section

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

The three properties that make up one elevation level Three columns show how offset and blur, surface colour, and border weight each change across three elevation levels, with a note that they must be shipped as one composite token. One elevation step, three things that must move together Offset + blur 0 1px 2px — level 1 0 4px 8px — level 2 0 12px 20px — level 3 Surface colour surface surface-raised surface-overlay Border weight hairline visible edge strong edge Ship elevation as a composite token, not three independent scales — a card that raises its shadow but keeps a flat surface reads as broken in dark mode, where the shadow contributes almost nothing.
Elevation is a composite. Shipping shadow, surface and border as separate scales lets them drift, and dark mode is where the drift becomes visible.
  • 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 Link to this section

  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 Link to this section

Two automated gates for an elevation scale A monotonicity gate that checks the scale is still ordered beside a theme parity gate that checks every level exists and differs meaningfully in both themes. Monotonicity check blur radius strictly increases with level y-offset never decreases catches a hand-edited level-2 that outranks level-3 Theme parity check every level defined in both themes dark levels differ by surface, not only opacity catches a dark theme that inherited light shadows Why both gates are needed Monotonicity proves the scale is still a scale. Parity proves the scale survived the theme. A system can pass one and fail the other: perfectly ordered light shadows that are invisible on a dark surface still read as a flat interface.
A scale can stay perfectly ordered and still disappear in dark mode. Order and parity are separate properties, so they need separate checks.

CI/CD Pipeline Configuration Link to this section

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 Link to this section

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 Link to this section

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-Domain Dependency Table Link to this section

Parent Section 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 Link to this section

/* @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 Link to this section

Framework-Agnostic Token Definition (Style Dictionary) Link to this section

{
  "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 Link to this section

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 Link to this section

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 Link to this section

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

How Many Elevation Levels a System Needs Link to this section

Elevation scales collapse under their own weight faster than any other token family, because every new component brings a plausible argument for a level between two existing ones. Bounding the count up front is what keeps the scale meaningful.

Five levels covers essentially every interface:

  • Level 0 — flush with the page. No shadow, and often no border either.
  • Level 1 — resting cards and panels. The lightest treatment that still separates a surface from the page.
  • Level 2 — raised controls and hovered cards. Distinctly above the page, still part of the layout.
  • Level 3 — dropdowns, popovers and menus. Floating above content that remains visible.
  • Level 4 — modals and dialogs, usually paired with a scrim rather than relying on shadow alone.

The reason to stop at five is that elevation communicates a stacking relationship, and human perception of stacking depth is coarse. Two levels that differ by a two-pixel blur radius are not perceived as different heights; they are perceived as an inconsistency. If a component genuinely needs to sit between levels 2 and 3, the usual correct answer is that its role is already covered by one of them.

Elevation and z-index are the same conversation Link to this section

A frequent failure is treating the visual scale and the stacking order as unrelated systems. They are two expressions of the same hierarchy, and when they disagree the result is a popover that looks like it floats above a modal but renders beneath it.

Bind them: publish a z-index token alongside each elevation level, generated from the same source, so that choosing level 3 gives a component both its shadow treatment and its stacking position. Any component that needs a z-index outside that set is making a claim about hierarchy that the elevation scale does not support, and that is a design conversation rather than a CSS one.

Frequently Asked Questions Link to this section

Should elevation tokens store the full box-shadow string or individual parts? Link to this section

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? Link to this section

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? Link to this section

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.

Inset Shadows Are a Separate Family Link to this section

Inset shadows express a different idea — a recessed well, a pressed control, a scrollable region with content above the fold — and they do not belong on the same numeric scale as raised elevation. Give them their own short family with names that describe the state they communicate, so that a component author is never choosing between “elevation 2” and “elevation minus 2”, which is a distinction that reads as arbitrary in review and in code.

Shadows Are Not Free Link to this section

Large blur radii are among the more expensive things a browser paints, and a list of several hundred cards each carrying a level-3 shadow is a measurable scroll cost on mid-range hardware. Where a shadow appears at scale, prefer the lowest level that still communicates the relationship, and consider whether a border would do the same job for a fraction of the paint budget.