Why One Box Shadow Looks Cheap, and How Layered Shadows Fix It
3 min read3 October 2026By Muhammad Abdullah
Most hand-written shadows look flat and slightly fake, and the reason is almost always the same: they use one layer. Real objects do not cast one shadow. Once you see the two shadows that light actually produces, building convincing elevation in CSS becomes a matter of stacking them.
The five values of a shadow
A box-shadow takes a horizontal offset, a vertical offset, a blur radius, a spread radius and a colour. Offsets move the shadow, blur softens its edge, and spread grows or shrinks the shape before any blurring happens.
/* x-offset y-offset blur spread colour */
box-shadow: 0 8px 24px -6px rgb(15 23 42 / 0.18);Real light makes two shadows
Look at a mug on a table. Right where it touches the surface there is a small, dark, sharp shadow. Further out there is a wide, faint, soft one. The first is the contact shadow; the second is ambient occlusion from light arriving at shallow angles. A single CSS shadow can be one or the other, never both, which is exactly why it reads as fake.
Stack the two and the element suddenly sits on the page:
.card {
box-shadow:
0 1px 2px rgb(15 23 42 / 0.12), /* contact: tight and dark */
0 8px 24px -6px rgb(15 23 42 / 0.18); /* ambient: wide and soft */
}Order matters. Shadows listed first paint on top, so the tight contact shadow goes first and the broad ambient one sits beneath it.
Negative spread is the value people skip
A large blur on its own spreads the shadow out in every direction, including upward and sideways, which produces a muddy halo around the whole element. Pairing it with a negative spread shrinks the shape first, so after blurring, only the soft edge peeks out below. That -6px in the ambient layer is doing real work: it keeps the shadow under the card instead of around it.
Do not use pure black
Pure black at any noticeable opacity is the most common reason a shadow looks cheap. Real shadows pick up the colour of the surface they fall on. A dark slate or a desaturated version of your background colour, at low opacity, reads as light rather than ink.
Build a small elevation scale
Rather than inventing a shadow for every component, define three levels and reuse them. Higher elevation means a larger offset and blur, and a slightly lower opacity, because a raised object casts a broader, softer shadow.
:root {
--shadow-sm: 0 1px 2px rgb(15 23 42 / 0.10);
--shadow-md: 0 1px 2px rgb(15 23 42 / 0.10),
0 4px 12px -2px rgb(15 23 42 / 0.12);
--shadow-lg: 0 2px 4px rgb(15 23 42 / 0.08),
0 16px 40px -8px rgb(15 23 42 / 0.20);
}Animate the opacity, not the shadow
Transitioning box-shadow directly forces the browser to repaint the shadow on every frame, which stutters on low-end devices. The cheaper approach is to put the hover shadow on a pseudo-element and fade its opacity, which the browser can composite without repainting.
.card {
position: relative;
box-shadow: var(--shadow-sm);
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: var(--shadow-lg);
opacity: 0;
transition: opacity 200ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }The pseudo-element has no background, so it never covers the card's content visually, and pointer-events: none keeps it from swallowing clicks.
Common questions
What is the difference between blur and spread?
Blur softens the edge outward. Spread changes the size of the shadow shape before blurring; a negative value shrinks it so a large blur does not spill out on every side.
How many shadow layers should I use?
Two covers most cases: a tight contact shadow and a wide ambient one. A third can add subtlety at high elevations, but beyond that the returns drop quickly.
Why is animating box-shadow slow?
Because it triggers a repaint every frame. Fading the opacity of a pseudo-element that already has the shadow avoids the repaint.