Why most CSS shadows look slightly off
The default shadow you see in most tutorials is something like box-shadow: 0 4px 6px rgba(0,0,0,0.1);. It works. It's inoffensive. But it doesn't look like a real shadow — it looks like a grey blur blob attached to the bottom of a card.
The reason is that physical shadows have two distinct components that CSS treats as one:
- The key shadow — cast by the primary light source (usually above or slightly in front of the object). This shadow is directional, relatively sharp, and darker close to the object.
- The ambient shadow — from the diffuse light bouncing off the environment. This shadow is non-directional, has a large spread, and is very soft and light.
Single-layer CSS shadows reproduce only one of these, which is why they look "digital" rather than physical. The fix is to use multiple box-shadow values (CSS allows comma-separated stacking).
The five parameters
CSS box-shadow takes: offset-x offset-y blur-radius spread-radius color
offset-x: Horizontal position. Positive = shadow moves right (light source is on the left). Negative = shadow moves left.offset-y: Vertical position. Positive = shadow moves down (light source is above). Most web designs have light from above, so this is almost always positive.blur-radius: How much the shadow blurs. 0 = hard edge. Larger values = softer, more diffuse shadow. This controls perceived distance from the surface.spread-radius: Expands or contracts the shadow before blurring. Positive values = shadow larger than the element. Negative values = smaller. Useful for creating tight, close shadows.color: The shadow color. Black (rgba(0,0,0,x)) is the default, but in real lighting, shadows are rarely pure black — they're a dark, slightly warm or cool version of the surface color.
A two-layer shadow system that works
Here's the structure I use in production for card components with varying elevation levels:
/* Elevation 1 — resting on a surface, barely lifted */
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.07), /* ambient */
0 1px 1px rgba(0, 0, 0, 0.04); /* key */
/* Elevation 2 — standard card at rest */
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.07), /* ambient */
0 1px 3px rgba(0, 0, 0, 0.06); /* key */
/* Elevation 3 — hovered card, slightly lifted */
box-shadow:
0 10px 15px rgba(0, 0, 0, 0.08), /* ambient */
0 4px 6px rgba(0, 0, 0, 0.05); /* key */
/* Elevation 4 — modal or dropdown, clearly above surface */
box-shadow:
0 20px 25px rgba(0, 0, 0, 0.10), /* ambient */
0 10px 10px rgba(0, 0, 0, 0.04); /* key */The ambient layer has a larger blur radius and lower offset — it's the soft halo around the object. The key layer has a smaller blur radius but slightly more Y offset and higher opacity — it's the directional shadow directly beneath the object.
Numbers from real design systems
I looked at the box-shadow values used by four production design systems to see whether the two-layer approach is the norm:
| System | Shadow layers | Opacity range |
|---|---|---|
| Tailwind CSS shadow-md | 2 | 7%–10% |
| Material Design 3 (elevation 2) | 2 | 12%–30% |
| Apple HIG card shadow | 1 (large blur) | 18% |
| Stripe dashboard card | 3 | 6%–8% |
Material Design uses higher opacity because it's designed for Android where screens often have higher ambient light. Web UIs generally work better with lower opacity shadows (6–12%) because monitors have a dark background of their own that increases perceived contrast.
Dark mode shadows: the invisible problem
Dark mode breaks box shadows. On a dark background, a dark shadow is invisible — the shadow color blends into the background before the blur can create contrast. Two solutions:
- Switch to a slightly lighter shadow color: Instead of
rgba(0,0,0,0.1), usergba(0,0,0,0.4)in dark mode. The background is darker, so you need more opacity to create the same perceived depth. - Use a border instead: Many well-designed dark interfaces (Linear, Vercel, GitHub dark mode) replace shadows with a subtle 1px border in a slightly lighter tone than the background. This creates separation without relying on shadow contrast.
border: 1px solid rgba(255,255,255,0.08)on a dark card is often cleaner than a shadow.
Colored shadows: the neon glow effect
Using a colored shadow (instead of black) is how the "neon glow" effect works in dark-mode dashboards. The shadow color matches or complements the element's own color:
/* Blue button with glow */
box-shadow: 0 0 20px rgba(96, 165, 250, 0.5);
/* Card with colored key shadow (warm card on warm background) */
box-shadow:
0 10px 15px rgba(59, 130, 246, 0.15),
0 4px 6px rgba(139, 92, 246, 0.10);Colored shadows read as light emission rather than shadow, which is why they work for buttons and interactive elements in dark interfaces. They don't work for cards in light mode — the colored haze looks odd against white backgrounds.
Spread radius for inset shadows
insetmoves the shadow inside the element. A negative spread radius on an inset shadow is how you create the "pressed button" effect:
/* Normal state */
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
/* Pressed state */
box-shadow: inset 0 2px 4px rgba(0,0,0,0.15);Inset shadows are also useful for input fields — a subtle inset shadow on focus reinforces the "input well" visual metaphor better than a border color change alone.
Using the box shadow generator
Tweaking five parameters across two layers is tedious by hand. The box shadow generator lets you adjust all parameters visually with live preview — switch to dark mode preview to check your shadows look right on dark backgrounds before shipping them.
Related tools
- Box Shadow Generator — build multi-layer CSS shadows visually with real-time preview.
- Color Picker — pick the exact RGBA values for shadow colors with opacity control.
- CSS Gradient Generator — combine shadows with background gradients for depth effects.
Written by Achraf A., founder of TheFreeAITools — built in Morocco. The elevation system described above is adapted from the one I use in this site's own UI components.