Elevation without shadows
Shadow is the absence of light. On a dark background there is barely any light to remove, so depth has to come from somewhere else.
↑ Click any of these to repaint the entire site with it.
Take a card with a well-tuned shadow on a light background and drop it onto #0b1220. The shadow does nothing. You can raise the opacity until it is black and it still does nothing, because the surface underneath is already almost black — there is no light there to remove.
This is not a limitation to work around. It is a different physical situation that needs a different mechanism.
The mechanism#
In a light interface, depth comes from occlusion: the raised object blocks light from reaching the surface below, and you read the resulting dark patch as distance.
In a dark interface, depth comes from proximity to the light source: surfaces closer to you catch more of whatever ambient light exists, so a raised surface is lighter than the one beneath it.
Material Design worked this out and encoded it as an overlay — a white layer at increasing opacity per elevation level. That works but produces desaturated, slightly gray surfaces. Deriving the surfaces in OKLCH instead keeps the hue and gives you the same effect without the wash:
:root {
--bg: oklch(19% 0.028 258); /* page */
--surface: oklch(23% 0.028 258); /* card */
--surface-raised: oklch(27% 0.028 258); /* popover */
--surface-overlay: oklch(31% 0.028 258); /* modal */
}Same hue, same chroma, lightness stepping about four points per level.
How much per step#
Four to five points of OKLCH lightness per level, in the dark. Fewer and the layers do not separate; more and by the fourth level you are at mid-gray and the whole thing has drifted away from your background color.
That gives you about four usable levels before you run out of room, which is fine — almost nothing needs more. Page, card, popover, modal. If you find yourself needing a fifth, the answer is usually that something in your hierarchy is nested one level too deep.
In a light interface it is the reverse: surfaces step darker as they rise, but only by two or three points, because the eye is more sensitive to lightness differences in the light range. Or — more commonly — the page is off-white, cards are pure white, and everything above that is separated by shadow.
Borders do the heavy lifting#
At four points of separation, a card on a page is a subtle edge. Add a one-pixel border a couple of points lighter again and the boundary becomes definite without becoming loud.
The number to aim for is a contrast ratio of about 1.2–1.6:1 between border and background. Below 1.2 it is invisible on a poorly calibrated screen. Above about 2 the border becomes a line the eye tracks, and a layout with many bordered elements starts to look like a spreadsheet.
--border: oklch(28% 0.02 258); /* ~1.35:1 against --bg */
--border-strong: oklch(35% 0.025 258); /* ~1.9:1, for focus and emphasis */Shadows still have one job#
Not for depth — for grounding. A modal with no shadow at all appears to float without weight. A very soft, very wide, very low-opacity shadow gives it a sense of mass even when you cannot consciously see the shadow itself.
--shadow-modal: 0 24px 60px -20px rgb(0 0 0 / 0.5);One layer, huge blur, negative spread. On a dark background it is nearly invisible and the modal still feels heavier with it than without.
And when you do use shadows on light backgrounds, tint them. A shadow on a warm cream page is a darker warm cream, not black. Pure black is only correct on a pure gray background, and nobody has one of those.
The trap: rgba(255,255,255,0.05)#
The common shortcut for dark elevation is a translucent white overlay. It works, and it has two costs.
It desaturates. White has no chroma, so every level up pulls your surfaces further toward gray. By the fourth level a deep blue background has become a slate that no longer matches the brand.
It composites unpredictably. Anything with its own background — an image, an inline SVG, a nested card — sits on top of the overlay rather than under it, so the elevation is inconsistent depending on what is inside the component.
Deriving opaque surface tokens avoids both. It costs four extra custom properties.
The test#
Build a page with four levels of nesting and no shadows at all. Page, card, popover inside the card, modal over everything.
If you can tell them apart, your surface scale works. If you cannot, no amount of shadow is going to rescue it in dark mode, because the shadow was never doing the work.