Elevation in dark mode: when drop shadows stop working
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