Layered Box Shadows — Why One blur() Looks Cheap

webdev box-shadow tools

A UI card ships with box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2) and looks like a sticker next to the Figma frame. Designers swear they used “just a soft shadow.” Dig into the inspect panel and you find three stacked effects with different offsets and opacities. One blur is rarely enough to fake physical light. Layered shadows are how CSS catches up without painting fake gradients behind every card.

What one shadow gets wrong

A single shadow tries to do two jobs at once: suggest a light source (sharp-ish contact) and suggest ambient occlusion (soft wraparound). Mid values compromise both. The result feels cheap: either too harsh or too vague, and never quite resting on the surface.

Physical-ish elevation usually needs at least:

  • A key (or contact) shadow: smaller blur, slight Y offset, modest alpha.
  • An ambient shadow: larger blur, lower alpha, sometimes wider spread.

Optional third layer: a hairline ring or inner highlight for dense UIs — not required for every card.

A readable two-layer recipe

.card {
  box-shadow:
    0 1px 2px rgba(15, 23, 42, 0.08),  /* key / contact */
    0 8px 24px rgba(15, 23, 42, 0.12); /* ambient */
}

Order matters visually because shadows paint in list order; keep the soft ambient and the tighter key intentional. Prefer slate-tinted rgba over pure black when the brand palette is cool — pure black shadows can look dirty on warm backgrounds.

Light mode vs dark mode

Copying the same shadow onto #111 backgrounds often fails. Dark surfaces already sit near black; heavy black shadows disappear or create muddy blobs. For dark UI:

  • Lower alpha further, or
  • Use slightly lighter shadow colors on near-black surfaces, or
  • Rely more on borders / hairlines and less on deep elevation.

Tokenize elevations: --shadow-sm, --shadow-md, --shadow-lg with theme overrides. Do not invent a new one-off shadow per component.

Hover and focus: motion without noise

Elevate on hover by swapping tokens, not by animating six independent layers with different timings. Prefer:

.card {
  box-shadow: var(--shadow-sm);
  transition: box-shadow 160ms ease, transform 160ms ease;
}
.card:hover {
  box-shadow: var(--shadow-md);
  transform: translateY(-1px);
}

Huge jumps feel gimmicky. One step up the elevation scale is enough for most product UI.

Performance and stacking contexts

Shadows are cheap until you animate large blurred layers across many cards during scroll. Prefer compositing-friendly transitions, avoid animating blur from 0 to 40px on fifty items, and watch filter: drop-shadow on huge DOM trees. For irregular icons, drop-shadow follows alpha; for rectangular cards, box-shadow is clearer.

Parent opacity and overflow clipping change how shadows appear. A card inside overflow: hidden can lose its ambient glow at the edges — elevate the shadow to a wrapper that is allowed to paint outside.

Matching Figma without cargo cult

When handoff looks wrong:

  1. Count layers in the design tool.
  2. Note offset, blur, spread, and opacity per layer.
  3. Recreate in CSS with comma-separated box-shadow.
  4. Preview on both #fff and #111 (or your real surface tokens).
  5. Adjust alpha before increasing blur forever.

A box-shadow generator helps iterate layers and copy CSS without guessing channel order. Keep the output in design tokens once it looks right.

Elevation scale beats one-offs

Define three elevations and reuse them: resting controls, raised cards, and modals/popovers. Modals may need a stronger ambient to separate from busy page content. Inputs often need a softer key than marketing hero cards. Consistency matters more than any single “perfect” blur.

One blur() looks cheap because real interfaces stack soft light. Build ambient plus key, theme them for dark surfaces, and stop treating shadow as a single magic number. The UI will feel grounded — and the Figma comparison will stop being an argument about taste and start being a checklist of layers.