Layering Shadow Tokens for Dark Mode Elevation
Part of Elevation & Shadow Tokens. A black shadow on a near-black surface conveys almost nothing. This guide restructures the elevation scale so each theme uses the channel that actually works on its background.
Prerequisites Link to this section
- An existing elevation scale with named levels, as built in tokenizing elevation values.
- Surface tokens that a theme block can re-point — the mechanism the whole approach depends on.
- Components that read an elevation token rather than declaring
box-shadowdirectly. - The theme parity gate, or the willingness to add it; this change makes parity checking essential rather than optional.
Step-by-Step Implementation Link to this section
Step 1 — Make elevation a composite of three tokens Link to this section
Intent: an elevation level is a surface, a border and a shadow together, so it must be expressible as all three.
:root {
--ds-elevation-2-surface: #ffffff;
--ds-elevation-2-border: #e2e8f0;
--ds-elevation-2-shadow: 0 4px 8px rgb(15 23 42 / 0.08);
}
[data-theme="dark"] {
--ds-elevation-2-surface: #334155; /* lighter than the page */
--ds-elevation-2-border: #475569; /* visible edge does more work */
--ds-elevation-2-shadow: 0 4px 8px rgb(0 0 0 / 0.4); /* subtle, for the edge */
}
Why this works: the component reads three names and gets a coherent treatment in either theme, without knowing which channel is doing the work. The alternative — a single shadow token with theme-specific values — cannot express “in dark mode the surface should also change”, which is the entire point.
Step 2 — Apply all three together in the component Link to this section
Intent: an elevation should be applied as a unit, never as a shadow on its own.
.card {
background: var(--ds-elevation-2-surface);
border: 1px solid var(--ds-elevation-2-border);
box-shadow: var(--ds-elevation-2-shadow);
}
/* A raised state changes the level, not the individual properties. */
.card:hover {
background: var(--ds-elevation-3-surface);
border-color: var(--ds-elevation-3-border);
box-shadow: var(--ds-elevation-3-shadow);
}
Why this works: changing all three on hover keeps the perceived relationship consistent in both themes. Changing only the shadow, which is the common shortcut, produces a hover state that is clearly visible in light mode and almost imperceptible in dark — the most frequently reported symptom of this whole problem.
Step 3 — Derive the dark surface ladder from the page background Link to this section
Intent: the surfaces must step upward from the page, with enough separation to be perceptible.
// scripts/derive-dark-elevation.mjs — steps up the neutral ramp per level.
const NEUTRAL = ['#0b1220', '#131c2e', '#1e293b', '#334155', '#475569'];
export const darkElevation = (level) => ({
surface: NEUTRAL[Math.min(level, NEUTRAL.length - 1)],
border: NEUTRAL[Math.min(level + 1, NEUTRAL.length - 1)],
shadow: `0 ${2 * level}px ${4 * level}px rgb(0 0 0 / ${0.25 + level * 0.05})`,
});
Why this works: deriving the ladder from a single neutral ramp guarantees the levels are ordered and evenly separated, which hand-picking does not. Setting the border one step further up the ramp than the surface gives every level a visible edge without a separate border scale — and that edge is what preserves the boundary when two levels sit adjacent.
Step 4 — Keep light-mode surfaces constant Link to this section
Intent: resist the symmetry instinct; light mode does not need a surface ladder.
In light mode a white surface on a very slightly grey page already reads as raised, and darkening successive levels would read as recessed rather than elevated — the opposite of the intent. The correct light-mode ladder keeps the surface at white or near-white across levels and varies only the shadow, which is what the original scale already did.
This asymmetry is worth stating explicitly in the token documentation, because it looks like an oversight. A designer reviewing the token file will notice that dark mode has five distinct surface values and light mode has two, and will reasonably ask why.
Step 5 — Gate ordering and parity together Link to this section
Intent: two checks, because the scale can be ordered in each theme and still be broken.
// scripts/check-elevation.mjs
import { readFileSync } from 'node:fs';
const m = JSON.parse(readFileSync('dist/token-manifest.json', 'utf8'));
const LEVELS = [0, 1, 2, 3, 4];
let failed = 0;
for (const theme of ['light', 'dark']) {
// Parity: every level defines all three parts.
for (const level of LEVELS) {
for (const part of ['surface', 'border', 'shadow']) {
const key = `--ds-elevation-${level}-${part}`;
if (!m[theme]?.[key]) {
console.error(`[${theme}] missing ${key}`); failed++;
}
}
}
// Ordering: blur radius must increase monotonically.
const blurs = LEVELS.map((l) => {
const v = m[theme]?.[`--ds-elevation-${l}-shadow`]?.value ?? '';
const parts = v.match(/(-?[\d.]+)px/g) ?? [];
return parseFloat(parts[2] ?? '0');
});
for (let i = 1; i < blurs.length; i++) {
if (blurs[i] < blurs[i - 1]) {
console.error(`[${theme}] level ${i} blur ${blurs[i]} < level ${i - 1} ${blurs[i - 1]}`);
failed++;
}
}
}
process.exit(failed ? 1 : 0);
Why this works: parity catches the level that was added to light and forgotten in dark, which is the most common drift. Ordering catches a hand-edited value that broke the scale. Neither catches the other, and the pair together is what makes the scale trustworthy enough for components to depend on.
Step 6 — Add a perceptual separation check for dark surfaces Link to this section
Intent: the dark ladder relies on adjacent surfaces being distinguishable, which contrast against text does not measure.
// Adjacent dark surfaces must differ enough to read as separate planes.
const MIN_STEP = 1.12; // ratio between adjacent surface luminances
for (let i = 1; i < LEVELS.length; i++) {
const a = luminance(m.dark[`--ds-elevation-${i - 1}-surface`].value);
const b = luminance(m.dark[`--ds-elevation-${i}-surface`].value);
const ratio = (b + 0.05) / (a + 0.05);
if (ratio < MIN_STEP) {
console.error(`[dark] level ${i} surface too close to level ${i - 1} (${ratio.toFixed(3)})`);
}
}
Why this works: this is the only check that measures the thing the dark ladder is actually doing. Two adjacent surfaces can both pass text contrast comfortably and still be indistinguishable from each other, which produces a dark theme where every card looks like it sits at the same height.
Verification Link to this section
Render the full set of levels adjacent to each other in both themes — a stack of five nested or tiled cards — and confirm each boundary is visible without effort. That single view catches more than any individual component test, because the failure is about relationships rather than about any one level.
Then check the two states that combine elevation with something else: a raised card on a raised surface, and a modal over a scrim. Both are places where the ladder runs out — a level-3 card inside a level-3 panel has nowhere further to go — and the correct answer is usually a design constraint rather than another level.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| Everything looks flat in dark mode | Only the shadow token was themed | Add surface and border parts and apply all three together |
| Hover barely registers in dark mode | Hover changes shadow only | Change the elevation level, not the shadow property |
| Deeply nested cards become indistinguishable | The ladder ran out of perceptible steps | Cap nesting depth in the design, or introduce a border-only variant |
| Dark surfaces look muddy | Steps are too small and the ramp is desaturated | Widen the steps; check the adjacent-surface ratio |
| Light mode looks recessed after the change | Surface darkening was applied to light mode by symmetry | Keep light-mode surfaces constant; only the shadow varies there |
Migration Note Link to this section
Converting a shadow-only scale is additive and safe. Add the surface and border parts for each level, defaulting the light theme’s surface to whatever the components already use — usually white — so light mode renders identically. Then design the dark ladder, which is the actual work.
Update components in the same pass as adding the parts, because a component that reads only the shadow token gains nothing from the new structure. The lint rule worth adding alongside is one that flags a box-shadow referencing an elevation token in a rule that does not also set a background, since that is precisely the pattern that leaves dark mode flat.
Expect the dark ladder to need adjustment after the first real page renders. The values that look right in isolation are frequently too close together once several levels appear together, and the adjacent-surface check from step 6 is what converts that judgement into a number you can tune against.
Elevation and Meaning Link to this section
One thing worth settling before tuning any values: what elevation is being used to communicate. Two different meanings are commonly conflated, and they want different treatments.
The first is stacking — this thing is in front of that thing, and interacting with it is modal or temporary. Dropdowns, popovers, dialogs and tooltips are all in this category, and for them elevation is functional: it tells a reader what is currently on top and where their attention belongs.
The second is grouping — this content is a distinct unit within the page. Cards, panels and sections use elevation this way, and here it is doing the job a border or a background tint could equally do.
The distinction matters in dark mode because the second use is where the shadow-only approach fails hardest and where the surface ladder works best. A card that reads as a unit because it is slightly lighter than the page is doing exactly what it should. A dialog, by contrast, needs to read as in front, which surface lightness alone does not convey — that case usually wants a scrim behind it, and the scrim is what carries the meaning in both themes.
Deciding which levels are for stacking and which are for grouping tends to simplify the scale. In most systems the answer is that levels 1 and 2 group, levels 3 and above stack, and the two halves can be tuned against different criteria rather than forced onto one continuum.
Related Link to this section
- Elevation & Shadow Tokens — the parent area, including how many levels a system needs
- Tokenizing Elevation Values for Consistent Depth — building the scale this guide themes
- Advanced Theming & Dark Mode Implementation — the theme mechanism the surface ladder depends on