Why naive inversion fails
The tempting shortcut — invert the light theme, or mirror every color's lightness — produces dark UIs that feel wrong in three predictable ways: brand blues turn electric, greys go muddy, and text either glares or sinks. The reason is that perceived lightness, chroma and contrast do not mirror symmetrically. OKLCH gives you independent control of exactly those three things, which is why a deliberate mapping beats an inversion.
The OKLCH method
- Move surfaces down, not to black. Large backgrounds live around L 0.15–0.25 (a slate like
#0f172asits at L 0.208). Pure black removes your ability to layer cards and borders above the background. - Bring text up, not to white. Body text around L 0.88–0.95 keeps contrast enormous without the halation of pure white on near-black. A light surface like
#f8fafc(L 0.984) becomes your text color's neighbourhood, slightly dimmed. - Keep hue, trim chroma. Your brand hue stays the same number — that is what makes the theme recognisably yours. Reduce chroma about 10–25% and lift lightness a little for accents, because saturated colors gain intensity against dark backgrounds.
- Rebuild elevation with lightness steps. In dark mode, “higher” surfaces are lighter (the opposite of shadows). Even L steps — 0.18, 0.22, 0.26 — give a calm elevation ladder because OKLCH steps are perceptually even.
Check the pairs, both metrics
Dark pairs are where WCAG ratios and APCA disagree most: WCAG treats white-on-black and black-on-white as the same ratio, while APCA (designed with polarity in mind) rates light text on dark backgrounds differently from the reverse. Use WCAG 2.2 for compliance and APCA as the readability sanity check — the WCAG vs APCA guide covers the details, and every pair can be tested in the contrast checker before it ships.
Why not pure black?
A #000000 page with #ffffff text is the maximum possible WCAG ratio (21:1), so it always passes, and on OLED panels true black can save power. The drawbacks are practical. Some readers, notably many people with astigmatism, report that bright text on a pure black field glows or smears (often called halation); the effect varies between people and displays, so treat it as a comfort risk rather than a rule. And with nothing darker than the page, borders and cards have nowhere to go. A dark slate such as #111827 with off-white text such as #e5e7eb keeps contrast very high without the glare.
Elevation without shadows
Drop shadows nearly vanish on dark backgrounds, so layered interfaces usually express height with lightness instead: page at the bottom, cards one step lighter, menus and dialogs lighter again. Keep the hue constant and move only L, with a slight chroma tint if your neutrals are tinted toward the brand. Two cautions: re-check text contrast on the lightest surface in the stack, not just the page, and add a faint border where two adjacent surfaces would otherwise merge. The scales guide shows how to generate evenly spaced neutral steps for this.
Contrast targets for dark themes
WCAG 2.2 applies unchanged: at least 4.5:1 for body text, 3:1 for large text, and 3:1 for meaningful non-text elements such as input borders, icons and focus rings (success criterion 1.4.11). APCA, a candidate method for the still-draft WCAG 3, is useful as an extra sanity check but is not a compliance requirement. Its sign encodes polarity: dark text on a light background gives a positive Lc, and light text on a dark background gives a negative one (read the magnitude). The table is calculated on this page, text color first, background second.
| Pair | Text | Background | WCAG ratio | AA body | APCA Lc |
|---|---|---|---|---|---|
| Off-white on dark slate | #e5e7eb | #111827 | 14.33:1 | Pass | -91.0 |
| White on pure black | #ffffff | #000000 | 21.00:1 | Pass | -107.9 |
| Muted grey on a raised surface | #9ca3af | #1f2937 | 5.78:1 | Pass | -48.6 |
| Dimmer grey on the same surface | #6b7280 | #1f2937 | 3.04:1 | Fail | -24.6 |
| Light-theme brand blue, unchanged | #2563eb | #0f172a | 3.45:1 | Fail | -25.7 |
| Same blue at L 0.72, chroma trimmed | #71a2ff | #0f172a | 7.07:1 | Pass | -51.5 |
- White on black reaches 21:1, yet the off-white slate pair is already above 14:1: there is no readability reason to go to the extreme.
- The dimmer grey on the raised surface is fine as a 3:1 UI color (placeholder borders, icons) but fails as body text, so keep secondary text one step brighter.
- The light-theme brand blue
#2563ebon dark slate falls short of 4.5:1. Lifting its OKLCH lightness to 0.72 and capping chroma (last row) clears it comfortably. The same check works for any pair in the contrast checker, and the contrast hub lists precomputed combinations.
Adapting brand colors
The usual fix for an accent that is too dark or too loud is to keep its hue, raise L, and trim C rather than just lightening in HSL. In the table above, only lightness and chroma changed between the last two rows. Check the result visually: a lifted blue should still read as the same brand, not a pastel. For a hue-specific starting point, browse a family such as blue, then use the brand tool or the OKLCH picker to adjust. Marketing pages follow the same logic; see the SaaS landing page palettes for pairs that survive both themes.
Implementing the switch
Define colors as CSS custom properties, override them for dark, and let the user choose. A common pattern follows the system setting by default and lets an explicit data-theme attribute win:
:root { color-scheme: light dark; --bg: oklch(0.99 0.005 250); --fg: oklch(0.2 0.02 250); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: oklch(0.2 0.02 250); --fg: oklch(0.92 0.01 250); }
}
:root[data-theme="dark"] { color-scheme: dark; --bg: oklch(0.2 0.02 250); --fg: oklch(0.92 0.01 250); }
:root[data-theme="light"] { color-scheme: light; }The color-scheme property tells the browser which built-in UI (scrollbars, form controls, default canvas) to render, so set it alongside your own colors. The newer CSS light-dark(lightValue, darkValue) function can collapse the two blocks into one declaration per property, but it depends on color-scheme and on browser support, so check your audience's browsers before relying on it and keep the custom property approach as a fallback. In Tailwind v4 the dark: variant follows prefers-color-scheme by default; to drive it from a toggle, redefine it with @custom-variant dark pointed at your data-theme selector. Persist the choice and set the attribute before first paint to avoid a flash of the wrong theme.
From guide to tokens
Once the mapping feels right for a few seed colors, let Studio generate the full dark ramp set — surfaces, text, borders, accents — from the same OKLCH values and export light and dark themes as matching CSS variables or Tailwind tokens. The scales guide explains how the underlying 50–950 ramps are built, so both themes share one system instead of drifting apart.