Shadows are not decoration
Most shadows on the web look like fuzzy gray borders. They technically separate layers, but they don’t feel real. If you want a UI to feel tactile, shadows are the cheapest way to fake depth.
This article is inspired by Josh W. Comeau’s deep dive on shadows, but I’ll focus on a practical recipe you can reuse in your own components.
Elevation is the real purpose
Shadows are a visual cue for elevation. Bigger shadow = closer to the user. That’s why elevation also controls focus and attention: your eyes naturally go to the closest thing.
Here’s a simple slider that controls the elevation value used to build the shadow:
Shadow Card
Slide the elevation to change the shadow.
Pick a single light source
In the real world, shadows depend on a light source.
In CSS, the “light source” is just your x and y offsets.
Pick a direction (top-left is the usual choice), and keep it consistent across the page:
:root {
--shadow-color: hsl(220deg 20% 20% / 0.25);
--shadow-x: 2px;
--shadow-y: 4px;
}
.card {
box-shadow: var(--shadow-x) var(--shadow-y) 12px var(--shadow-color);
}
If every component uses a different angle or offset, the page will feel messy, even if each shadow looks nice in isolation.
Build a tiny recipe
Instead of guessing numbers, build a small formula that scales with elevation:
.card {
--elevation: 8;
--x: calc(var(--elevation) * 0.25px);
--y: calc(var(--elevation) * 0.9px);
--blur: calc(var(--elevation) * 1.6px + 6px);
--spread: calc(var(--elevation) * -0.15px);
box-shadow: var(--x) var(--y) var(--blur) var(--spread) hsl(220deg 20% 20% / 0.25);
}
Once the formula feels right, every component can share it.
The idea is simple:
- Offset (
--xand--y) keeps a consistent light direction. - Blur grows faster than elevation so higher cards feel softer.
- Negative spread keeps the shadow from bloating outward.
You can tweak the multipliers, but keep the relationship. That is what makes the shadow system feel coherent across the page.
Want to build modern interfaces with less JavaScript?
I wrote a guide about building modern UI with HTML and CSS first. It covers practical patterns you can use before reaching for JavaScript.
Learn moreLayering makes shadows feel real
A single shadow rarely looks natural.
Real shadows are layered: a tight shadow near the object, and softer ones farther away.
Try toggling the layers to see how they combine:
Layer toggles
The simplest pattern is 3 layers:
.card {
box-shadow:
0 2px 4px hsl(220deg 20% 20% / 0.18),
0 8px 14px hsl(220deg 25% 20% / 0.14),
0 20px 32px hsl(220deg 30% 20% / 0.12);
}
Think of these as three zones:
- Contact shadow (first line): short blur, higher opacity. This anchors the card to the surface.
- Mid shadow (second line): medium blur, slightly lighter. It adds body and depth.
- Ambient shadow (third line): large blur, faint opacity. This creates the soft halo that feels natural.
Notice how the blur radius grows faster than the offset, and the opacity decreases as the shadow gets larger. That balance is what keeps the shadow feeling realistic instead of heavy.
Color-match your shadows
Neutral gray shadows are okay on a white background, but they break on colorful surfaces.
If the surface is warm, the shadow should be warm too.
This demo keeps the hue and saturation from the surface and only darkens the lightness:
This tiny change makes shadows feel grounded instead of pasted on top.
Putting it together
Combine the recipe, the layers, and a color-matched shadow, and you get a reusable system:
.card {
--elevation: 12;
--shadow-color: hsl(18deg 40% 20% / 0.28);
box-shadow:
0 2px 4px var(--shadow-color),
0 8px 16px hsl(18deg 40% 20% / 0.2),
0 18px 30px hsl(18deg 40% 20% / 0.16);
}
At this point, you can map --elevation to design tokens and scale across your UI.
Bonus: drop-shadow
box-shadow always uses the element’s box.
filter: drop-shadow() follows the actual rendered shape, including transparent parts.
It’s perfect for speech bubbles, cutouts, or icons:
Tail gets no shadow
Shadow follows the shape
Here is the basic HTML and the tiny CSS that creates the bubble tip:
<div class="bubble">Bubble text</div>
.bubble {
position: relative;
background: white;
padding: 1rem 1.2rem;
border-radius: 10px;
}
.bubble::after {
content: '';
position: absolute;
left: 24px;
bottom: -14px;
width: 24px;
height: 18px;
background: inherit;
clip-path: polygon(50% 100%, 0 0, 100% 0);
}
.bubble {
filter: drop-shadow(0 10px 18px hsl(220deg 20% 20% / 0.18));
}
Final thoughts
Shadows don’t need to be complicated.
Pick a light source, scale your numbers, layer your blur, and tint the color.
That’s enough to make your UI feel more intentional and more real.