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. Here's why that happens, and what to derive them from instead.
Break #1: the angle drifts
In a real scene there is one light, so every object casts its shadow in the same direction. 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:
shadowDirection = (azimuth + 180) % 360
Shadows fall away from the light, which is the whole content of that line. Every level then shares one azimuth, and 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.
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.
One 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 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. 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. Offset and blur grow together, the top layer's opacity falls from 0.16 to 0.094, and the direction never changes. That last part is the 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, 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.
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 reference. Variables can carry the numeric elevation values (4, 6.8, 11.6…) as FLOATs, but they cannot carry the shadow itself.
There's a subtler trap next to it. It's tempting to bind the shadow colour to a COLOR variable so the 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.
I build Elevation, a Figma plugin that does this maths for you: drag one light source, get the five-level scale, export it as effect styles, CSS custom properties, a Tailwind boxShadow config or W3C design tokens. The editor and applying shadows are free.
The longer version of this post is on my blog.
Top comments (0)