// color_theory · dark_mode_second_palette
Dark Mode Is a Second Palette, Not an Inverted Filter
The fastest way to ship a dark theme is to swap the page background to near-black, swap body text to near-white, and leave every other color untouched. It technically works, and it is almost always wrong: shadows stop reading as depth once there's no light surface left to darken, saturated accent colors that looked confident on white start to glow against black, and contrast pairs that passed easily in the light theme can fail once they're actually measured against their dark counterparts instead of assumed to carry over.
§ 01
Why Inverting a Light Theme Doesn't Work
A literal inversion — flip the page background from paper to near-black, flip body text from ink to near-white, leave every brand color, shadow, and border exactly where it was — is the shape most first dark themes take, because it's the cheapest possible change and it does technically produce a dark screen. The problem is that every other decision in the light theme was tuned for a light surface, and none of those decisions survive the swap unexamined.
Shadows are the clearest casualty. A drop shadow communicates depth by darkening a light surface underneath a raised element — on a surface that's already near-black, there's almost nothing left to darken, so the shadow either disappears or has to be faked with an unnaturally heavy blur that reads as a smudge rather than depth. Saturated accent colors are the second casualty: a hue calibrated to look vivid but controlled against white can look like it's glowing once the surrounding field goes dark, a real perceptual effect and not just a stylistic preference. And contrast is the third: the specific ramp steps that produced a passing WCAG ratio in the light theme are not the same steps a naive inversion resolves to, so nothing about a light-theme pairing passing guarantees its dark counterpart does too.
A dark theme that holds up is built as a second, independently-tuned resolution of the same semantic tokens described in the companion guide on palette tokens, not a filter layered over the first one. That means making three decisions on purpose instead of by default: how elevation reads without relying on shadow, how much chroma an accent hue can carry before it starts to vibrate against a dark surface, and how contrast gets re-verified against the dark theme's actual resolved colors rather than inherited from the light theme's numbers. The rest of this guide works through those three in order.
§ 02
Elevation Through Lightness, Not Shadow
On a light surface, elevation is communicated almost entirely by shadow: a raised card gets a soft, offset drop-shadow, a flat surface gets none, and the eye reads the shadow's falloff as physical depth. That technique depends on the shadow being visibly darker than the surface it's cast on — which is exactly the relationship a dark surface can't offer. Cast the same shadow onto a surface that's already near-black and the darkening it would add is nearly imperceptible, so a UI that relies on shadow alone for hierarchy goes visually flat the moment it's inverted.
The fix, popularized by Material Design's dark theme guidelines and now common across mature dark UIs, is to communicate elevation by making raised surfaces lighter instead of casting a shadow onto them — treat each elevation level as a small, semi-transparent white overlay laid over the base surface color, with the overlay's opacity increasing at higher elevations. The result is a surface that gets measurably brighter the "closer" it is to the viewer, which is a depth cue that works in exactly the direction a dark theme actually has room to move in.
| Elevation | Light theme | Dark theme |
|---|---|---|
| Base (0) | Flat, no shadow | Base surface, darkest step |
| Raised (card) | Small offset shadow | Small white-overlay lift |
| Floating (menu) | Medium shadow, more blur | Larger overlay lift |
| Modal / dialog | Largest shadow + scrim | Largest overlay lift + scrim |
The exact opacity curve is a house convention rather than a fixed law — what matters structurally is that it's monotonic (higher elevation is never darker than the level below it) and that it's generated by a rule instead of picked per component, for the same reason a tonal ramp is generated rather than hand-picked: consistency across dozens of components only survives if every one of them is deriving its surface color from the same small function.
§ 03
Building the Dark Surface Ramp
The alias-swap model from the companion guide on tokens — a
semantic name like color.background.surface
resolving to a light ramp step in one theme and a dark ramp step
in the other — is the right starting structure, but a typical
light-mode ramp isn't built with dark-mode surfaces in mind. Most
ramps carry only one or two very dark steps near the bottom,
because in a light theme those steps are used almost exclusively
for high-emphasis text, not for several stacked, closely related
surface colors. Elevation needs more resolution than that: enough
distinct, closely spaced steps near the dark end to represent a
base surface plus several raised levels above it without any two
levels collapsing into the same visible shade.
The practical fix is a dedicated surface sub-ramp — a small set of
steps generated with tighter lightness deltas than the primitive
ramp uses elsewhere, purpose-built for elevation rather than
borrowed from the general-purpose scale. It's also worth resisting
true black as the base:
oklch(0% …) looks like the obvious floor for a
"near-black" surface, but a true-black base combined with the
highest overlay steps needed for a deep elevation stack tends to
make the top of the stack jump further than intended, since
there's more distance for the overlay to travel. A base around the
lower end of the perceptual scale — enough above true black to
leave headroom for several overlay steps above it, without reading
as anything other than "black" at a glance — gives the ramp
somewhere controlled to climb from.
| Token | Role | Relative to base |
|---|---|---|
| surface-0 | Page background | Darkest step, not literal black |
| surface-1 | Cards, list rows | Small, consistent lift |
| surface-2 | Menus, popovers | Larger lift than surface-1 |
| surface-3 | Modals, dialogs | Largest lift in the set |
None of the surface-0 through surface-3 labels need to be new primitives from scratch — they can just as easily be extra intermediate stops threaded into the existing primitive ramp near its dark end. The requirement is only that the ramp actually has enough steps there to give elevation somewhere to go; a ramp with a single "gray-900" and nothing beneath it has nowhere for a four-level elevation system to live.
§ 04
Desaturating Hues So They Don't Vibrate on Black
Perceived saturation isn't a property of a color in isolation — it's judged relative to what surrounds it, a well-documented effect of simultaneous contrast. The practical consequence for interface color: an accent at a given chroma value that reads as "vivid but controlled" against a light, low-chroma surface can read as considerably more intense, even uncomfortably so, against a near-black one, without the color itself changing at all. Warm, highly saturated hues — hot pinks, saturated reds and oranges — tend to show the effect most strongly, sitting at the boundary some designers describe as a color "vibrating," where the eye has trouble settling on a stable edge between the accent and the surface behind it.
The fix isn't to abandon saturated brand colors in dark mode, it's to resolve them to a different point on the same hue rather than reusing the identical light-theme primitive step. In OKLCH terms that generally means pulling chroma down and nudging lightness up slightly for any accent that sits directly on a dark surface — keeping the hue angle fixed, since that's what carries brand recognition, while giving up some of the saturation that a light background could carry without incident.
dark-accent: C' ≈ C × 0.75–0.90 · L' ≈ L + 0.03–0.08 · H unchanged
Treat that as a starting range to tune by eye against the actual dark surface color in use, not a formula to apply blindly — the right amount of reduction depends on the hue, the base chroma, and how large an area the accent covers (a small icon can usually carry more chroma than a full-width banner in the same hue before it starts to feel loud).
oklch() values, so actual appearance may shift
slightly by display and color-management settings.
§ 05
Text and Icon Color: Never Pure White
The same instinct that inverts #000 text to
#fff also tends to reach for literal
#ffffff as the dark theme's primary text color, since
it's the most direct "opposite" of black. In practice it's rarely
the right choice. Pure white against a near-black surface is the
most extreme contrast pair a UI can produce, and at that extreme
the boundary between glyph and background can visibly bloom or
halo, especially on OLED and high-density displays and for viewers
with astigmatism — a real perceptual cost for a pairing that,
ironically, already clears WCAG's contrast minimums with enormous
margin to spare.
A softened near-white — a neutral step sitting a few percentage
points below full lightness, sometimes with the faintest cool or
warm tint pulled from the brand's hue rather than a perfectly
neutral gray — keeps body text comfortable to read for long
stretches while still passing contrast requirements with plenty of
room. Reserve literal
#ffffff for small, high-emphasis moments where the
extra "pop" is doing real work — a single stat, an active-state
icon — rather than using it as the default color for paragraphs of
body copy.
§ 06
Re-Deriving Contrast Ratios From Scratch
It's tempting to assume a pairing that passes WCAG contrast in the light theme automatically passes in the dark theme, since "the numbers just flip." That assumption doesn't hold once real ramp steps are involved instead of literal black-and-white endpoints: the relative luminance values of a ramp's intermediate steps don't mirror symmetrically around its midpoint, so a pairing built from step three and step eight in the light theme doesn't necessarily produce the same ratio as whatever steps the dark theme actually resolves that same semantic pairing to. Every text-on-surface, icon-on-surface, and state pairing needs to be measured again, independently, against the dark theme's own resolved colors — not inherited from the light theme's results.
| Pairing | Minimum | Re-check when |
|---|---|---|
| Primary text / surface-0 | 4.5:1 | Text color or base surface changes |
| Secondary text / surface-1 | 4.5:1 | Any elevation overlay opacity changes |
| Icon / surface-0 | 3:1 | Icon token or surface token changes |
| Accent text / surface-2 | 4.5:1 | Dark-mode chroma reduction is retuned |
| Danger text / surface-0 | 4.5:1 | Feedback hue ramp is retuned |
It's also worth knowing that WCAG 2.x's ratio formula isn't the last word on how contrast is actually perceived — an emerging alternative called APCA (the Advanced Perceptual Contrast Algorithm) models polarity explicitly, meaning it treats light text on a dark surface and dark text on a light surface as genuinely different situations rather than mirror images of the same ratio. APCA isn't yet the enforceable standard the way WCAG 2.x is, but it's a useful second opinion when a pairing passes the letter of WCAG 2.x and still looks uncomfortably thin in an actual dark UI.
§ 07
Semantic Tokens Stay the Same, Values Don't
None of the previous three sections change the token architecture itself — they change what a dark theme's aliases are allowed to point to. The semantic layer still exposes the same names to every component regardless of theme; what's different from the simple gray-50/gray-900 swap example is that some aliases now resolve to values that were purpose-built for the dark theme rather than reused from the light theme's ramp — the surface sub-ramp from section three, and the desaturated accent from section four.
:root {
--color-background-surface: var(--gray-50);
--color-text-primary: var(--gray-900);
--color-action-primary: var(--blue-500);
}
[data-theme="dark"] {
/* Surface: dedicated dark sub-ramp, not a literal #000 base */
--color-background-surface: var(--surface-0);
--surface-1: /* +overlay */;
--surface-2: /* ++overlay */;
/* Text: softened near-white, not literal #fff */
--color-text-primary: var(--gray-50-soft);
/* Accent: re-resolved hue, not the light-theme primitive reused */
--color-action-primary: var(--blue-400-dark-accent);
}
Every one of these still resolves through the same
color.background.surface /
color.text.primary /
color.action.primary semantic names a component
already references — nothing downstream has to know a dark accent
isn't the literal same primitive as the light-theme accent. That's
the whole benefit of keeping theme resolution as its own tier: the
dark theme gets to make genuinely different color decisions
without a single component needing new logic to consume them.
§ 08
Testing a Dark Theme Properly
A dark theme that only gets checked by toggling it on once and glancing at the homepage will ship with problems a slightly longer pass would have caught. A few checks worth treating as standard before calling it done:
Both toggle and system preference. Verify the
theme responds correctly to both a manual in-app toggle and the
OS-level prefers-color-scheme: dark
media query — it's common for one path to be wired up and tested
while the other silently falls back to the light theme.
Every pairing, not just the obvious ones. Run the full contrast matrix from section six again after any ramp or accent retune, not only on the pairings that seem likely to be affected — a single elevation-overlay change can shift several surface tokens at once.
Assets that assume a light background. Logos, illustrations, and icons drawn with dark strokes on a transparent background can become invisible or nearly so once the surface behind them goes dark; each one needs a theme-aware variant or a currentColor-based approach rather than a fixed dark stroke color.
Focus states and forced-colors mode. Focus rings tuned for visibility on a light surface can lose contrast against a dark one, and Windows' forced-colors mode overrides author colors entirely — both are worth a dedicated pass rather than an assumption that whatever worked in light mode still works.
A real device, not just a simulator. OLED panels, ambient glare, and ambient light sensors that shift perceived contrast all behave differently from a color-accurate monitor in a dim room — a theme that looks perfect in a design tool is worth a final look on an actual phone in normal daylight.
§ 09
Five Pitfalls That Look Like Polish
-
01
Treating dark mode as a CSS
filter: invert()over the light theme. It inverts every image, icon, and photo along with the UI chrome, producing broken visuals everywhere the site actually has content instead of flat color. -
02
Keeping
box-shadowas the only depth cue. Shadows lose almost all their visual effect against a near-black surface, so any hierarchy that depends on them alone goes flat the moment the theme switches. -
03
Reusing the exact same saturated primitive step for an accent in both themes. The identical value that reads as confident on a light surface reads as intense or "buzzing" on a dark one, the simultaneous-contrast effect section four covers.
-
04
Shipping literal
#ffffffas the default body text color. It clears contrast minimums with room to spare while still being genuinely harder to read for long stretches and prone to visible halation on OLED displays. -
05
Assuming a light-mode-passing pairing survives into dark mode untested. The steps each side of the pairing resolves to changed between themes, so the ratio has to be independently re-measured, not carried over as an assumption.
§ 10
Quick Reference
| Concept | Typical value | Note |
|---|---|---|
| Base surface L | Low, not literal 0% | Leaves headroom for overlay steps above it |
| Elevation cue | Lightness overlay | Replaces shadow, which barely reads on dark |
| Accent chroma | C × 0.75–0.90 | Reduced relative to the light-theme step |
| Accent lightness | L + 0.03–0.08 | Paired with the chroma reduction |
| Primary text | Softened near-white | Not literal #ffffff for body copy |
| AA text minimum | 4.5:1 normal · 3:1 large/UI | Re-verified per theme, never inherited |
| Contrast model | WCAG 2.x ratio | APCA useful as a second opinion, not yet the standard |
| Theme switch mechanism | Alias reassignment | Same semantic names, independently tuned values |
A dark theme earns its keep the same way the tonal ramp in the companion guide does: not by matching any specific percentage listed here, but by treating "dark mode" as a second, deliberately tuned resolution of the same semantic layer rather than a filter bolted onto the first one. Once elevation stops depending on shadow, accents get a chroma budget of their own, and every contrast pairing is verified against what the dark theme actually resolves to, the specific numbers become easy to retune later without touching anything else — which is the entire point of keeping the three layers separate in the first place.