// 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.

INTERMEDIATE · 12 MIN READ · COLOR THEORY

§ 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_model_light_vs_dark
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.

Shadow-based elevation on a light surface compared with lightness-overlay elevation on a dark surface Two rows of three stacked cards. The top row sits on a light background and each higher card gets a heavier drop shadow to read as more elevated. The bottom row sits on a near-black background and each higher card is simply a lighter shade of gray, with no shadow at all, and still reads as more elevated than the one beneath it. LIGHT SURFACE — DEPTH VIA SHADOW base +shadow ++shadow DARK SURFACE — DEPTH VIA LIGHTNESS OVERLAY base surface +4% overlay +9% overlay no shadow used anywhere in the bottom row
FIG. 01 — Illustrative opacity values. The structural point is the direction of change: light-theme elevation gets darker shadows, dark-theme elevation gets lighter surfaces, and nothing in the bottom row depends on a shadow being visible at all.

§ 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.

/ dark_surface_sub_ramp
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).

The same accent hue at full chroma versus reduced chroma, shown against a light and a dark surface A two by two grid of color swatches. Top left: a saturated pink on a white background, reading as bold but calm. Top right: the identical saturated pink on a near-black background, reading as noticeably more intense. Bottom left: the same pink on white, unchanged. Bottom right: a desaturated, slightly lighter version of the same pink on the near-black background, reading closer in intensity to the top left swatch. SAME PRIMITIVE STEP, BOTH THEMES on white — reads fine on near-black — vibrates DARK-THEME ACCENT RE-RESOLVED on white — unchanged on near-black — reduced C, calmer
FIG. 02 — Same hue angle throughout; only chroma and lightness move for the dark-surface pairing. Rendered with live 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.

/ dark_theme_pairing_matrix
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-shadow as 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 #ffffff as 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

/ dark_mode_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.

DARK MODE COLOR THEORY ELEVATION CONTRAST DESIGN TOKENS