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.
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-shadowvs.filter: drop-shadow():box-shadowperforms 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. Usebox-shadowfor 95% of UI surfaces and reservefilter: drop-shadow()for iconography and SVG overlays.- Z-index isolation vs. elevation coupling: Elevation tokens must not carry embedded
z-indexvalues. Couple them conceptually — Level 3 elevation impliesz-index: 300— but store those as separate tokens. CSS stacking contexts (isolation: isolate) contain depth layers and prevent globalz-indexconflicts; elevation tokens should not attempt to encode that containment. - Static vs. dynamic shadow composition: A single
box-shadowstring 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-colorprimitive 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
- Design tool export — Shadow styles in Figma are exported via the Tokens Studio plugin as JSON, using the DTCG
$type: shadowschema. Each entry maps a semantic name to offset, blur, spread, and color fields. - 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. - Style Dictionary compilation — The transform pipeline resolves primitive references and outputs a single
elevation.cssfile with:root-scoped custom properties. A custom formatter flattens multi-part shadow objects into validbox-shadowstrings. - 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. - CSS variable injection — Compiled output is imported once in the application entry point (or via
@importin the design system’s root stylesheet). Shadow DOM consumers repeat the:rootblock as:host. - Stylelint enforcement — A pre-commit hook runs Stylelint with
declaration-property-value-disallowed-listto reject any rawbox-shadowvalue that bypasses the token registry. - 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.
- 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.filesin 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.
Related
- Token Fundamentals & Naming Conventions — parent reference covering the full three-tier primitive → semantic → component token hierarchy
- Tokenizing Elevation Values for Consistent Depth — step-by-step implementation of the decomposed elevation token strategy used in the build pipeline above
- Spacing & Layout Tokens — offset-y values in shadow tokens should align with the spacing scale to maintain optical proportionality
- Color Palette Architecture — shadow color tokens should reference the color primitive tier rather than inline RGBA strings