Web Developmentguide12 min read

Dark Mode Tokens That Fail Quietly

Inverted hex is not a theme. Separate surfaces, text, and borders or pay in QA.

Dark mode shipped as white text on black with the light-mode accent unchanged. The hero screenshot looked cinematic. The settings page did not. Muted captions at OKLCH lightness zero point five five on a surface at zero point one eight failed contrast by a full WCAG level. Focus rings disappeared against raised cards. Border colors that framed panels crisply in light mode vanished into the same near-black as the page background. QA filed twelve tickets. Engineering applied twelve one-off fixes. Three months later, dark mode and light mode shared no token relationship at all.

Inverted hex is not a theme. A theme is a coordinated set of surface, text, border, accent, and semantic tokens whose relationships survive a mode switch. Dark mode fails quietly because failures do not look broken in the way a missing image looks broken. They look almost right, just slightly fatiguing, slightly hard to read, slightly flat. Users notice as exhaustion, not as a bug report.

This article covers the elevation model dark UI depends on, the tradeoffs between prefers-color-scheme and data-theme, failure modes teams repeat, and OKLCH token pairs you can adopt. The coordinate system is OKLCH, built on Björn Ottosson’s Oklab, because perceptual lightness steps make dark surfaces behave predictably. CSS Color 4 gave browsers the syntax. MDN documents the media query that starts most implementations.

Why inversion destroys semantic relationships

The naive approach, whether filter: invert(1) or programmatically flipping each hex, destroys relationships that took weeks to tune in light mode. Accents tuned for white backgrounds stay saturated on true black. A brand blue that read as professional on a light surface becomes neon on charcoal. Shadows stop working because elevation in light UI is depth via shadow plus a slightly raised surface. On black, shadows are invisible. You need lightness shifts instead.

Borders need opposite polarity. Light mode uses faint gray borders to separate white cards from off-white pages. Dark mode needs brighter borders on darker surfaces. Images and icons invert with everything else unless you carve exceptions, creating maintenance debt that ships in the same pull request as color tokens and fails QA together. Semantic success, warning, and danger colors need higher lightness in dark mode, not inverted values.

Themes need paired token sets where relationships are explicit. If text-muted sits zero point three three lightness steps below text-primary in light mode, a similar gap should hold in dark mode, adjusted for the darker surface context. Perceptual brightness is not symmetric. Teams that reuse the same OKLCH lightness for muted text across themes often fail captions in dark UI while headlines pass, exactly the quiet regression paired tokens are meant to surface early.

Material Design popularized the idea that dark UI surfaces get lighter as they rise, not darker. A page background sits at the lowest lightness. Cards float above it. Modals and dropdowns float higher. Each level bumps lightness slightly while keeping hue and chroma restrained. In OKLCH that is a few decimal points on the L channel, not a new palette invented per component.

The elevation model in OKLCH coordinates

Surface zero is the page background. Surface one is cards and panels. Surface two is dropdowns and sticky headers. Surface three is modals and popovers. In light mode, elevation often relies on white surfaces plus shadow because the background is already off-white. In dark mode, shadow alone fails. Lightness bumps carry the hierarchy.

:root {
  --surface-0: oklch(0.97 0.01 260);
  --surface-1: oklch(1.00 0 0);
  --text-primary: oklch(0.22 0.02 260);
  --text-muted: oklch(0.55 0.02 260);
  --border-subtle: oklch(0.88 0.01 260);
}

[data-theme="dark"] {
  --surface-0: oklch(0.16 0.02 260);
  --surface-1: oklch(0.20 0.02 260);
  --surface-2: oklch(0.24 0.02 260);
  --surface-3: oklch(0.28 0.02 260);
  --text-primary: oklch(0.93 0.01 260);
  --text-muted: oklch(0.62 0.02 260);
  --border-subtle: oklch(0.28 0.02 260);
}

Surface zero in dark mode at zero point one six is darker than surface one at zero point two zero. The card is visibly raised without relying on shadow alone. Text-muted at zero point six two on surface zero at zero point one six maintains readable contrast, unlike reusing the light-mode muted value at zero point five five, which would fail. Borders sit above the surface they frame in lightness terms, mimicking how light catches edges in physical interfaces.

Shadow tokens weaken in light mode and strengthen in dark mode. Higher shadow alpha on dark surfaces restores depth cues. Combine with surface lightness bumps for modals that actually feel elevated rather than floating rectangles with identical fill.

When the settings page betrayed the hero screenshot

A productivity startup shipped dark mode for a launch video. Marketing captured the hero with true black background, unchanged cobalt accent, and white headlines. The video performed well. The product’s settings area, never filmed, used the same token file without paired values. Muted helper text reused light-mode coordinates. Focus rings reused light-mode accent at zero point five five lightness. Card borders reused a divider token meant for pale gray on white.

Support volume did not spike. Session length in settings dropped. Accessibility audit at contract renewal flagged contrast failures on form hints, table metadata, and empty-state copy. The failures were all token problems, but engineers had fixed them locally with component overrides during the launch crunch. By audit time, three different muted grays existed across settings, billing, and integrations, each justified as a hotfix.

The remediation sprint started with a contrast matrix, not with design opinion. Every text token was tested against surface zero through surface three in both modes. WCAG 2.2 AA was the contractual floor. The matrix exposed that muted text needed zero point six zero to zero point six five lightness in dark mode, not zero point five five copied from light. Secondary text needed to lighten rather than darken relative to light mode, a counterintuitive result that HSL inversion would never produce correctly.

Form controls received paired --field-border, --field-bg, and --placeholder tokens engineering had previously treated as gray primitives. Placeholder text failed before body text because placeholders sat on surface one while hints sat on surface zero. The matrix caught the split because rows were organized by surface, not by token name. Focus rings on inputs needed the same brighter treatment as button focus rings. A single --focus-ring token simplified audit evidence for the accessibility packet.

Engineering removed twelve component-level filter: brightness() hacks marketing had added to make cards look elevated in the launch video. Those hacks desaturated badge colors and made screenshots non-representative of token values. Elevation returned to surface lightness steps and shadow alpha tokens where it belonged. The hero could keep a cinematic gradient overlay without polluting application shell tokens.

Accent tokens were rebuilt with higher lightness and slightly reduced chroma so brand color did not glow on near-black. Hover polarity inverted: on light surfaces hover darkens by subtracting lightness; on dark surfaces hover lightens by adding lightness. Status colors received paired values where success and danger shared dark-mode lightness because they shared UI role, while warning stayed highest to avoid brown drift.

The team wired tokens through both prefers-color-scheme and data-theme, with explicit user choice winning when set. A small inline script in the document head read localStorage and set data-theme before first paint to prevent flash of wrong theme. color-scheme: light dark on the root ensured native form controls matched. Within four weeks, component overrides were removed and the contrast matrix became a CI attachment on every token pull request.

The lesson they documented internally was that dark mode quality is judged on dense screens, not heroes. Heroes forgive true black and neon accent. Settings pages expose muted text, borders, and focus rings. If your QA plan only screenshots marketing URLs, you will ship quiet failures.

prefers-color-scheme, data-theme, and paired semantics

prefers-color-scheme reflects OS-level settings. Users who set macOS or Windows to dark mode expect your site to follow on first visit, at minimum. data-theme reflects explicit user choice from a settings toggle. That choice should override OS preference until the user changes it. Most production apps need both, with clear priority: explicit data-theme wins, otherwise dark preference from the OS, otherwise light defaults from :root.

Accent and semantic colors need paired values, not inverted hex. Dark mode bumps accent lightness and slightly reduces chroma. Success and danger share dark-mode lightness because they share role in badges and alerts. Warning stays highest because yellow-family hues need headroom. Focus rings tuned for white backgrounds at zero point five five lightness disappear on zero point two zero cards. Dark mode needs a brighter ring, often higher lightness and slightly lower chroma.

:root {
  --accent: oklch(0.55 0.18 264);
  --success: oklch(0.52 0.14 145);
  --focus-ring: oklch(0.55 0.18 264);
}

[data-theme="dark"] {
  --accent: oklch(0.68 0.16 264);
  --success: oklch(0.72 0.14 145);
  --focus-ring: oklch(0.72 0.14 264);
}

Common failures repeat across teams. Reusing light-mode muted text fails captions on dark gray. True black page backgrounds look premium in marketing and harsh in data-dense UI; prefer zero point one four to zero point one eight for application shells. Colored shadows copied from light mode leak on dark cards. Logo PNGs with white backgrounds float as rectangles. Chart palettes optimized for white vanish on dark. Each has a token or asset fix, but they ship together and fail QA together.

Auditing and integration with the wider stack

Run a contrast matrix on real font sizes, not token pairs in isolation. Secondary text that passes against surface default may fail on surface raised cards, a common SaaS layout. Squint at elevation: can you distinguish surface levels without reading content? If not, widen lightness gaps by zero point zero three to zero point zero four OKLCH steps. Verify toggle persistence without flash on load.

If you use Tailwind v4 @theme, dark tokens belong in the same CSS file as light tokens, inside prefers-color-scheme blocks or toggled via a class on the document root. Figma variables should export both modes as collections with matching names so color/surface/0 in light and dark map to the same CSS custom property with only the value changing.

Dark mode tokens fix color relationships. They do not fix typography weight, motion preferences, or content that assumes a light canvas. They also do not replace wide-gamut considerations. Dark mode on a Display P3 MacBook can render accents more vividly than your sRGB development monitor shows. Re-run contrast on P3-enhanced tokens if you bump chroma for capable displays.

Chart and data visualization colors need the same paired treatment as surfaces and text. D3, Chart.js, and canvas renderers often accept palette arrays configured once at module load. If those arrays read light-mode OKLCH seeds only, series lines that separated cleanly on white vanish on charcoal. Maintain a --chart-series-* token set or pass theme-aware palettes from the same OKLCH seeds your buttons use. The visualization layer is easy to forget because it ships in the same dark-mode pull request as tokens but lives in JavaScript configuration files engineering does not associate with CSS custom properties.

Image and logo assets remain outside token math but inside dark-mode quality. PNG logos with white backgrounds float as rectangles on raised cards. SVG wordmarks without currentColor paths need light and dark asset variants or a single adaptable SVG. Document asset rules beside color tokens in the dark-mode spec so QA checks logos in the same pass as muted text. Users blame the theme for floating rectangles even when every text token passes contrast.

Halation on OLED true black is a separate readability concern from contrast ratios. Thin white text on #000000 can glow on some panels. Slightly lifting surface zero to zero point one four through zero point one eight reduces eye strain in data-dense layouts without sacrificing the cinematic hero that marketing still wants for campaigns. You can scope true black to .hero--marketing while the application shell uses paired surface tokens. That is a product decision semantic naming supports because surface-0 and surface-hero are different roles, not different hex accidents.

Auditing cadence matters after launch. Dark mode is not ship once. New components introduce new one-off grays. Token PRs without dark paired values slip through when reviewers focus on light screenshots. Add a dark column to your Storybook global types or Playwright snapshot matrix. Require both modes in the definition of done for any feature touching color, background, border-color, or outline-color.

Relative color syntax pairs well with dark tokens when hover polarity inverts. A single .btn:hover rule can lighten in dark mode while darkening in light mode if theme blocks set different base accents and you adjust the calc direction per theme. That avoids maintaining --accent-hover-light and --accent-hover-dark as separate static values while still honoring the perceptual feedback users expect on each background polarity.

Documentation for paired tokens should live beside the contrast matrix, not in a separate design handbook engineers never open. When --text-muted changes, the matrix row and the Figma mode value should update in the same pull request. That coupling is how you prevent quiet regression three sprints later when a well-meaning lightness tweak helps captions on surface zero and breaks hints on surface one.

The quiet failures, muted text that almost passes, borders that almost separate, cards that almost float, are token problems. Build paired sets in OKLCH, wire them through prefers-color-scheme and data-theme, bump surfaces instead of inverting them, and test contrast on real backgrounds instead of on white. Inverted hex is a hack. Paired tokens are a theme.