Skip to content
← writing

Elevation in dark mode: when drop shadows stop working

css
dark-mode
box-shadow
frontend

On a light background, depth is easy: drop a soft shadow and the card lifts. In dark mode that same shadow is nearly invisible — there's no light surface for it to darken. You crank the opacity, it turns into a muddy black halo, and it still doesn't look raised. Dark UIs need a different model.

Five steps, resting to floating. Depth here is four cues working together.

Cue 1–3: the surface

Before any ambient shadow, every elevated surface gets the same three layers:

box-shadow:
  inset 0 1px 0 0 rgba(255,255,255,0.08),  /* top light line */
  inset 0 0 0 1px rgba(255,255,255,0.04),  /* inner hairline */
  0 0 0 1px rgba(0,0,0,0.16);              /* outer edge ring */

The inset top line fakes light catching the upper edge — the single biggest trick for "raised" in dark mode. The inner hairline keeps the fill from looking flat, and the outer ring is a 1px dark separator so the surface doesn't bleed into a near-black background. Together they define a plane, even at rest with no shadow at all.

Cue 4: ambient shadows that double

Elevation is then just how many ambient layers you stack on top:

0 1px 1px  -0.5px rgba(0,0,0,0.18)
0 3px 3px  -1.5px rgba(0,0,0,0.18)
0 6px 6px  -3px   rgba(0,0,0,0.18)
0 12px 12px -6px  rgba(0,0,0,0.18)

Two things make this read as real light:

  • Offset and blur double each step (1 → 3 → 6 → 12). A real penumbra grows non-linearly with height, so doubling feels physically right.
  • Negative spread (−0.5 → −1.5 → −3 → −6) pulls every layer inward by half its blur. Without it the layers pile into one dark blob; with it each stays a soft, distinct band — the difference between "smooth shadow" and "drop shadow with the opacity turned up."

Level 1 is surface-only (a resting card). Each step up adds the next layer, so level 5 — a sticky or floating bar — carries all four.

Make it a token, not a one-off

Stash the surface in a custom property and append ambient layers per level:

.dm-elev   { --dm-surface:
               inset 0 1px 0 0 rgba(255,255,255,.08),
               inset 0 0 0 1px rgba(255,255,255,.04),
               0 0 0 1px rgba(0,0,0,.16); }
.dm-elev-3 { box-shadow: var(--dm-surface),
               0 1px 1px -0.5px rgba(0,0,0,.18),
               0 3px 3px -1.5px rgba(0,0,0,.18); }

Now elevation is a scale you reach for by name — level={3} for a popover, level={5} for a floating bar — instead of hand-tuning shadows per component.

Takeaways

  • Drop shadows alone don't work in dark mode; lead with an inset top light line and an edge ring to define the surface.
  • Stack ambient shadows whose offset/blur double and whose spread goes negative — soft and distinct, never muddy.
  • Bake it into a 1–5 scale so elevation is a token, not a guess.

Grab the component and copy any level's stack on the Dark Elevation page.

Ask your agent to implement this

Read the full writeup at https://seangeng.com/writing/dark-mode-elevation.md and implement it in my project.

It covers: Elevation in dark mode: when drop shadows stop working — A single drop shadow vanishes on a dark background. Here's the layered box-shadow system that actually reads depth in dark UIs — a top light line, an inner hairline, an edge ring, and ambient shadows that double with negative spread.

Requirements:
- Follow the technique/approach exactly as described in the writeup.
- Adapt names, colors, and styling to my project's existing conventions.
- If it's a component, make it reusable with sensible props and TypeScript types.
- Keep it accessible: semantic HTML, keyboard support, and respect prefers-reduced-motion.
- When done, tell me which files you created or changed and how to use it.

Paste into Claude Code, Codex, Cursor, or any agent. view raw .md