The first attempt at dark mode is nearly always an inversion. Take the light palette, flip the luminance, ship it. It looks approximately right in a screenshot and falls apart the moment somebody uses it.
Why inversion fails
A light theme is built on a specific relationship between surfaces: the page is the darker plane and a card sits above it, lighter, catching the light. Invert that mechanically and the card is now darker than the page — which is a legitimate look, but it is not the one the components were designed for, and every shadow in the system is now pointing the wrong way.
The deeper problem is that the two ramps are not symmetrical. Light themes tend to run from white down through a narrow band of greys. Dark themes run from near-black up through a much wider one. A pair of surfaces that reads as a clear step in one direction can flatten to nothing in the other.
The rule that fixes it
Stop thinking in colours and start thinking in roles. A surface is not “grey 100”; it is “the plane a card sits on”. Once the role is named, each theme answers it independently:
:root {
--background: var(--color-base-50);
--card: var(--color-white);
}
.dark {
--background: var(--color-gray-950);
--card: var(--color-gray-900);
}
Note that these are not inversions of each other, and they do not need to be. Each theme picks the value that plays the role correctly on its own ramp.
The case that still needs care
Some surfaces genuinely do not have a parallel answer. A recessed panel — a block that should sit below the surrounding band — is a clear step down in dark and almost invisible in light, because light’s deep surface is a different token entirely.
Those get an explicit pair, written on the element so the two values cannot drift apart:
<div class="bg-muted dark:bg-background">…</div>
It looks like a special case because it is one. The discipline is to check every surface relationship against both ramps rather than assuming the one you designed in holds in the other.
Things that should not flip
Not everything is a token. An accent colour that is deliberately identical in both themes should be declared once, not repeated in both blocks — a value that does not flip has no business in a per-theme block. And an image belonging to somebody else’s brand is a fact rather than a decision; it ships as exported, in both themes.






