Every dark mode I've looked at closely, including mine, sits on a background that isn't black and greys that aren't grey. Here are the seven values I use instead, why pure black flattens a page, and the two token traps that make your colours quietly disappear.
Turn the lights off and open your own site. Don't look at the layout, just the background, because if it's #000000 you're looking at a hole and not a surface. Mine is #0a0a0b, a value nobody can see and everybody feels.
I didn't expect to care about this. Then I moved my own site to dark mode, and every card I placed on the page became a slightly lighter rectangle floating in the same nothing, because the background had nowhere left to go. Inverting a light theme gives you contrast. It doesn't give you depth, and depth is the thing that tells your eye a modal sits above the page instead of being printed on it.
Black is not a surface
A surface needs somewhere to sit. If the page is #000000, there's nothing below it, so a modal can't sit above the page and a dropdown can't cast a shadow that means anything. Everything lands on one plane and the interface reads as a sheet of text with buttons stuck to it.
So I gave each level its own value, and each one does a separate job:
| Step | Value | Where it goes |
|---|---|---|
| Deep | #060607 | The void behind the page, footers, pressed states |
| Page | #0a0a0b | The default background |
| Card | #131316 | Anything that floats: cards, popovers, code blocks |
| Hover | #1c1c20 | A surface under the cursor |
Then two values that aren't surfaces at all. Borders are white at 10%, and text comes in three levels: 44% for the quiet stuff, 66% for body copy, 92% for headings. Three is enough. If you need a fourth, the surfaces underneath are too close together to read as separate layers anyway.
That's seven values for a whole theme, which is fewer than most themes I've opened. Plenty of them ship dozens that overlap, and after a while nobody remembers which grey belongs to a card and which one belongs to a popover above it.
Your grey is blue
Look at the numbers instead of the swatch. A true neutral has equal red, green and blue, and almost nothing you get handed does.
Slate 900 is #0f172a, which is red 15, green 23, blue 42. That isn't grey with a shadow on it. It's blue sitting 27 points above red. Zinc is quieter at #18181b, and even that carries a 3 point lift toward blue.
Mine is #0a0a0b, a spread of one point. It reads as charcoal rather than blue-grey, and on a phone at low brightness the difference is obvious once you've seen both side by side.
The check takes ten seconds. Open your background in a colour picker. If the three numbers aren't within three of each other, your neutral has an opinion you didn't choose.
The trap that cost me an afternoon
Two things broke quietly, and neither of them threw an error.
The first was opacity utilities. I remapped black and white to my own tokens, which felt tidy until I wrote bg-black/50 somewhere and got nothing at all. A Tailwind opacity modifier only works if the token is stored as bare channels, because the compiler appends the alpha value itself. Store a hex value and every bg-black/50 in the codebase compiles to no CSS. No warning, no error, just an element nobody can see.
black: "rgb(var(--black) / <alpha-value>)",
white: "rgb(var(--white) / <alpha-value>)",
The second was tinted themes. I wanted a few accent variants without writing a separate stylesheet for each, so one accent colour gets mixed into the background and into the text separately. In practice it's one CSS function, color-mix(in srgb, var(--user) 45%, #fdfdfc), repeated six times, and every surface inherits the tint. A whole second theme out of one value.
These two tricks also fight each other. Mix a background and what you get back is a computed value, not a set of channels, so a token that used to survive an opacity modifier stops surviving it. Which rule wins? I ended up keeping one: channels for anything that ever needs alpha, mixes for anything that doesn't.
How to check a theme before shipping
Four checks, in the order I do them.
Look at the site on a phone at low brightness, in a dark room. That's where a blue cast and 44% text both fail at once.
Put every elevation next to the one underneath it. If a card and the page look identical, what you have is three greys pretending to be a ladder.
Check the light theme as well. A ladder that only works in one mode is half a system, and these bugs usually show up in reverse: text that reads fine in dark mode turns to mush at 66% black on paper white.
Last, read the muted text out loud on the smallest screen you support. 44% is my floor. Below it, people with ordinary eyesight lean in closer, and the people with worse eyesight leave.
- Pure black leaves no room below the page, so nothing can sit above it.
- Read the numbers. A neutral 27 points off is blue.
- Store colour as channels, or opacity utilities compile to nothing.