L LocaleProof Pro Get Elevation free

Design systems · Shadows

Why your five shadows don't look like one system

Nobody hand-tunes one shadow and gets it wrong. The problem starts at five. You nudge each elevation level until it looks right on its own, and the finished set looks like five shadows from five different rooms. This is why that happens, and what to derive them from instead.

Published August 2026 · 7 min read

The short version

A hand-tuned shadow set breaks in three predictable places: the angle drifts between levels, the blur ramp is arbitrary, and opacity stacks into grey. All three disappear if you stop authoring shadows and start authoring a light source — one azimuth, one distance, one brightness for the whole scale. The rest is arithmetic, and it's below, with a ramp you can paste.

Break #1: the angle drifts

In a real scene there is one light, so every object casts its shadow in the same direction. Change nothing else and break just this, and the eye notices immediately — not as "the shadows are inconsistent" but as something closer to nausea. A card whose shadow falls down-right next to a modal whose shadow falls straight down reads as two photographs pasted together.

Drift is almost never a decision. It's what happens when level 4 is tuned three weeks after level 1, in a different file, against a different background. The fix isn't discipline, it's removing the opportunity: derive the direction once, from the light.

shadowDirection = (azimuth + 180) % 360

Shadows fall away from the light, which is the whole content of that line. Every level in the scale then shares one azimuth, and the drift becomes unrepresentable rather than merely discouraged.

Break #2: the blur ramp is arbitrary

Ask five designers for elevation blurs and you'll get 4/8/16/24/32, or 2/6/12/24/48, or whatever landed. Those are guesses dressed as a scale. Physically, blur isn't a free parameter — it's governed by how far away the light is. A distant light is effectively a point source and casts a crisp edge; a nearby one wraps around the object and smears it:

penumbra = 1 / (1 + distance / 200)
blur     = elevation × (0.6 + 1.4 × penumbra)

Two things follow that hand-tuning tends to miss. First, blur is tied to elevation: something floating higher is necessarily blurrier, so you cannot pick blur per level independently and stay coherent. Second, blur never reaches zero — a raised element sitting on a hard-edged shadow reads as a sticker, not as an object above a surface.

The levels themselves come from one number too. Elevation grows geometrically, which is what makes the steps feel evenly spaced rather than crowded at the bottom:

elevation(n) = base × growth^(n-1)

base 4px, growth 1.7  →  4 · 6.8 · 11.6 · 19.7 · 33.4 px

Break #3: opacity stacks into grey

This is the one people misdiagnose most often. A shadow that looks dirty is almost never a blur problem — it's an opacity problem. One layer at 0.30 reads as a grey box under your card. Two or three layers between roughly 0.06 and 0.16 read as light. Same darkness, completely different impression.

The reason is that a real shadow isn't one blurred rectangle. Close to the object there's a dense umbra; further out it fades into a wide, faint penumbra. Stacking a few layers with growing blur and falling opacity approximates that falloff. Below, the innermost layer sits at 35% of the full travel and the outermost at 100%; blur runs from 0.5× to 2.0× the base; and the outer layer gives up 55% of the inner layer's opacity.

One more constraint matters more than it looks: keep the whole ramp inside a band. Below about 0.04 a shadow is invisible and you're shipping dead CSS; above about 0.24 it stops reading as light. Clamping to that band is what stops a design system from slowly darkening as people "make it a bit more visible" over two years.

And higher things cast fainter shadows — the same light spreads across more area, so density per pixel drops. A square root keeps level 5 from vanishing entirely while still fading it:

brightness(n) = brightness / √n

The ramp, ready to paste

Everything above, run with one light — azimuth 0°, distance 200, base elevation 4px, brightness 0.6, three layers per level. This is generated output, not a hand-picked example:

:root {
  --elevation-1: 0px 1.3px 2.6px rgba(0,0,0,0.16),
                 0px 2.4px 6.5px rgba(0,0,0,0.127),
                 0px 3.6px 10.4px rgba(0,0,0,0.094);
  --elevation-2: 0px 2.1px 4.4px rgba(0,0,0,0.125),
                 0px 4.1px 11px rgba(0,0,0,0.102),
                 0px 6.1px 17.7px rgba(0,0,0,0.078);
  --elevation-3: 0px 3.7px 7.5px rgba(0,0,0,0.109),
                 0px 7px 18.8px rgba(0,0,0,0.09),
                 0px 10.4px 30.2px rgba(0,0,0,0.071);
  --elevation-4: 0px 6.2px 12.8px rgba(0,0,0,0.1),
                 0px 12px 32px rgba(0,0,0,0.083),
                 0px 17.7px 51.2px rgba(0,0,0,0.067);
  --elevation-5: 0px 10.5px 21.7px rgba(0,0,0,0.094),
                 0px 20.3px 54.3px rgba(0,0,0,0.079),
                 0px 30.1px 86.8px rgba(0,0,0,0.064);
}

Notice what the numbers do across levels. The offset and blur grow together, the opacity of the top layer falls from 0.16 to 0.094, and the direction never changes. That last part is the whole point: the set is one light photographed five times, not five shadows.

The Figma trap: a shadow can't be a variable

If you're planning to hold this scale as Figma variables, stop and check the type list first. Figma variables resolve to exactly four types — BOOLEAN, COLOR, FLOAT and STRING. There is no shadow type and no effect type. This is a platform limit, not a gap in your setup, and it's worth knowing before you design the token structure rather than halfway through building it.

So the shareable primitive for a shadow system has to be an effect style — that's what publishes to a library and what components can actually reference. Variables can still carry the numeric elevation values (4, 6.8, 11.6…) as FLOATs, which is genuinely useful for spacing and for documentation, but they cannot carry the shadow itself.

There's a subtler trap right next to it. It's tempting to bind the shadow colour to a COLOR variable so the whole system is themeable. Don't — a COLOR variable carries its own alpha, and one shared alpha across a three-layer stack flattens exactly the opacity ramp that made the shadow look like light. You'd get the grey box back, this time by architecture.

Or drag one light and get the whole scale

Elevation is a Figma plugin that does exactly the maths above: you drag a single light source and it derives a five-level elevation scale, then hands it to your codebase as effect styles, CSS custom properties, a Tailwind boxShadow config or W3C design tokens. The editor, the presets and applying shadows are free.

FAQ

How many elevation levels should a design system have?

Five covers most product UI: raised surface, card, dropdown, overlay, modal. What matters more than the count is that consecutive levels are visibly different — if two levels are indistinguishable in context, you have four levels and a rounding error, not five.

How many layers should one shadow have?

Two or three. One layer cannot express both a dense umbra and a wide penumbra, so it always looks either hard or muddy. Beyond three the returns vanish and you're paying render cost for differences nobody can see.

Why does my shadow look grey and dirty?

Almost always opacity, not blur. Check the total: if any single layer is above ~0.24, it reads as a grey box rather than as light. Splitting the same darkness across two or three low-opacity layers fixes it more reliably than any blur adjustment.

Can I store shadows as Figma variables?

No. Figma variables resolve only to BOOLEAN, COLOR, FLOAT or STRING, so shadows have to live as effect styles. Variables can hold the numeric elevation values alongside them, but the shadow itself is an effect style — plan the token structure around that.

Does this work for coloured shadows?

Yes — the direction, blur and opacity maths are independent of hue. Tinting the shadow toward the surface colour beneath it usually looks better than pure black, especially on saturated backgrounds. Keep the opacity band the same.