Open any design system's shadow tokens and you'll see something like this: box-shadow: 0 4px 12px rgba(0,0,0,0.15). One layer. Fixed offset. Arbitrary blur. It works in the sense that there's a shadow there. But it doesn't look real. It looks like someone drew a blurry rectangle under your card and called it a day.
That's basically what happened. CSS box-shadow is a blurred copy of the element's shape, offset by whatever values you give it. It has no concept of light, no understanding of distance, no falloff curve. It's a rendering primitive, not a lighting model.
Real shadows don't work this way. In the physical world, an object's shadow changes based on three things: how high the object is above the surface, where the light is coming from, and how diffuse the light source is. Higher objects cast softer, more spread shadows. Light sources create directional offsets. Ambient light fills in the edges. None of this happens with a single box-shadow value.
The shortcut that doesn't work: cranking up blur radius to simulate elevation. A
blur: 40pxshadow looks foggy and unnatural. Real elevation needs multiple shadow layers at different sizes and opacities, not one big blurry mess.
One shadow vs. layered shadows
Here's the difference at a glance. Same perceived elevation, two completely different approaches. The single-layer shadow uses one big blur. The layered version stacks three shadows with different sizes and opacities to simulate how light actually behaves.
Single layer vs. multi-layer shadow
The layered version uses less total opacity but looks more grounded
The single shadow looks like the card is floating in fog. The multi-layer version looks like it's sitting on a table. The trick is that real shadows are actually made up of several overlapping components:
Layer 1 (contact shadow): A very small, tight, slightly dark shadow right at the base. This is the shadow where the object almost touches the surface. Tiny offset, tiny blur, slightly higher opacity.
Layer 2 (mid shadow): A medium-sized shadow that represents the main body of the cast shadow. Moderate offset and blur, moderate opacity.
Layer 3 (ambient shadow): A large, very soft, very faint shadow. This is the light wrapping around the object from all directions. Big blur, very low opacity.
/*
* Three-layer shadow anatomy.
* Each layer simulates a different light behavior.
*/
.card-elevated {
box-shadow:
/* Contact: tight, dark, grounding */
0 1px 2px rgba(0,0,0, 0.08),
/* Mid: the main cast shadow */
0 4px 8px rgba(0,0,0, 0.05),
/* Ambient: soft, diffuse, wrapping */
0 14px 32px rgba(0,0,0, 0.06);
}
Elevation as a continuous scale
In a real elevation system, you don't just have "shadow" and "no shadow." You have a continuous scale from 0 (flat on the surface) to something like 24 or 32 (floating high above it). Each level should change all three shadow layers proportionally. Drag the slider to see how it works:
Elevation scale, 0 to 32
All three layers scale together. The card lifts as elevation increases.
Notice how at low elevations the contact shadow dominates. The card barely lifts and the shadow is tight and crisp. As elevation increases, the ambient layer grows while the contact shadow stays small. This is how light works. The higher something is, the more its shadow spreads and softens, but it still has that little anchor shadow where it's closest to the ground.
The opacity trick: total combined opacity should stay roughly constant as elevation increases. If you just increase blur without reducing opacity per layer, higher elevations look way too dark. Spread the shadow out but keep the total light absorption consistent.
Shadows have a direction
Most shadow systems use a fixed offset (usually straight down or slightly down-right). But light doesn't always come from the same place. Move your mouse around the box below to change the light source position and watch how the shadow direction changes:
Interactive light source
Move cursor over the surface to reposition light
Shadow offset is the inverse of the light position relative to the object
This matters in practice because your UI has a consistent light source direction, even if you've never explicitly defined one. Most design systems assume top-center light (positive Y offset, near-zero X offset). If you're mixing shadows with different offset directions, it creates an uncanny feeling. Like some objects are lit from above and others from the side.
Pick a light direction for your entire app and stick with it. Top-center or slightly top-left is the safest. Define the X and Y offset ratios once and derive all your shadows from that.
A complete elevation token scale
Here's what a production-ready shadow scale looks like. Six levels, each with three layers, all derived from the same formula:
Shadow elevation tokens
Each level stacks 3 shadow layers at calibrated sizes and opacities
/*
* Elevation tokens.
* Each level = contact + mid + ambient layers.
* Total opacity stays roughly ~0.18 across all levels.
*/
:root {
--shadow-0: none;
--shadow-1:
0 1px 1px rgba(0,0,0,.07),
0 2px 4px rgba(0,0,0,.05),
0 4px 10px rgba(0,0,0,.04);
--shadow-2:
0 1px 2px rgba(0,0,0,.07),
0 4px 8px rgba(0,0,0,.05),
0 12px 28px rgba(0,0,0,.06);
--shadow-3:
0 2px 3px rgba(0,0,0,.06),
0 8px 16px rgba(0,0,0,.05),
0 20px 44px rgba(0,0,0,.06);
--shadow-4:
0 2px 4px rgba(0,0,0,.05),
0 12px 24px rgba(0,0,0,.05),
0 28px 56px rgba(0,0,0,.07);
--shadow-5:
0 3px 5px rgba(0,0,0,.04),
0 16px 32px rgba(0,0,0,.05),
0 36px 72px rgba(0,0,0,.08);
}
Making it work in practice
1. Use elevation semantically
Don't just slap shadow-3 on things because it looks nice. Elevation communicates hierarchy. A navbar at elevation 1 sits above page content. A modal at elevation 4 sits above the navbar. A tooltip at elevation 5 sits above everything. If two things at the same z-index have different shadow levels, it reads as a bug.
2. Shadows on dark backgrounds need different treatment
On dark backgrounds, rgba(0,0,0,x) shadows become almost invisible because there's no contrast. You need higher opacity (2x to 3x what you'd use on white) and you should consider adding a subtle light border or inner glow to the elevated element instead. Some design systems use a lighter background color for elevated surfaces on dark themes rather than relying on shadows at all.
3. Animate elevation, not just shadows
When a card lifts on hover, transition the entire box-shadow property. But also add a tiny translateY(-1px) or (-2px) so the card physically moves up. Shadow without movement looks like the lighting changed. Shadow with movement looks like the card moved. That's the difference between spooky and natural.
4. Colored shadows for colored surfaces
If your card has a colored background, the shadow should pick up that color. A blue card with a black shadow looks disconnected. A blue card with a dark desaturated blue shadow looks physically correct. In CSS, just swap the rgba for a color-matched value: box-shadow: 0 8px 20px rgba(59,130,246, 0.25).
The big idea
Shadows are the cheapest way to add depth and hierarchy to a flat interface. But they only work if they're physically plausible. Our brains are incredibly tuned to how light and shadow interact. We can't articulate what's wrong with a fake shadow, but we feel it immediately. Something just looks "off" or "cheap."
The fix isn't complicated. Three layers instead of one. Consistent light direction. Scale all three proportionally. Keep total opacity steady. That's it. You can set this up in 20 minutes and your entire app will look more polished than 90% of what's out there.
Good shadows are invisible. Nobody looks at a card and thinks "wow, what a beautiful shadow." They just feel that the interface has depth, that elements have weight, that there's a spatial logic to the layout. That's the goal. Not to make people notice your shadows, but to make them stop noticing that something's wrong.