CSS / UI

Dark Mode Done Right: The Details Most Implementations Get Wrong

3 min read

Dark mode is one of those features that looks trivial from the outside — "just make the background dark" — and reveals its depth the moment you ship it. Pure white text vibrating on pure black, brand colors that glow radioactive, shadows that vanish, contrast failures that passed fine in light mode. Having built theming for products where the request always eventually arrives, here's what separates a real dark mode from an inverted-colors hack.

The "inverted colors" problem

The naive implementation flips the palette: #fff becomes #000, done. The result fails in predictable ways. Pure white on pure black produces harsh halation — maximum-contrast text is genuinely harder to read in the dark. Colors that carried meaning in light mode (a warning amber, a success green) lose contrast or turn garish on dark backgrounds. Elevation breaks: shadows are invisible on black, so your card hierarchy silently disappears. Dark mode isn't an inversion — it's a second palette that expresses the same design language under different physics.

Foundation: CSS custom properties, not hardcoded colors

Theming is impossible if colors are scattered as literals across a thousand rules. The prerequisite is that components reference roles, and only the theme defines values:

:root {
  --bg: #f6f5f0;
  --surface: #ffffff;
  --text: #1b1e23;
  --text-soft: #5b6068;
  --border: #dedbd1;
  --accent: #4a47e0;
}
 
.card {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
}

Now dark mode is one block redefining six variables — not a .dark override for every component. Two rules make this work at scale: name by role, not by color (--surface, never --white — a variable named --white holding #1a1d24 is a maintenance trap), and no literals in components, enforced in review. This is design tokens doing their job.

Respect the system preference by default

Users already declared their preference at the OS level. Honor it without asking:

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #16181d;
    --surface: #1f2229;
    --text: #e8e6df;
    --text-soft: #a7abb4;
    --border: #33373f;
    --accent: #8b89f0;
  }
}

Notice the values: the dark background is near-black, not black; text is near-white, not white; and the accent got lighter, not darker — more on that below. If you also offer a manual toggle (worth doing), the logic is: default to the system preference, store an explicit user choice, and apply it via a class or data-theme attribute that overrides the media query. And set color-scheme: light dark on :root so native UI — scrollbars, form controls — themes itself to match.

Check contrast separately for dark mode

The failure nobody expects: colors that pass WCAG AA on white fail on dark. Contrast is a ratio against the actual background, and dark surfaces are dimmer than white, so mid-tone colors lose headroom. That indigo accent above is the canonical case — #4A47E0 reads clearly on paper-white but muddies against #16181D; the dark theme needs the lightened #8B89F0 to keep its ratio.

The practical rule: your dark palette needs its own contrast pass — every text/background pair re-checked, just like the light one. While you're at it: reduce saturation generally (saturated colors bloom on dark), and replace shadow-based elevation with lighter surfaces for raised elements, since shadows can't do the job anymore.

The working token setup

Everything above, in one copy-pasteable pattern with a manual override:

:root {
  color-scheme: light dark;
  --bg: #f6f5f0;
  --text: #1b1e23;
  --accent: #4a47e0;
}
 
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --bg: #16181d;
    --text: #e8e6df;
    --accent: #8b89f0;
  }
}
 
:root[data-theme='dark'] {
  --bg: #16181d;
  --text: #e8e6df;
  --accent: #8b89f0;
}

System preference honored by default; an explicit data-theme (set by your toggle, persisted in localStorage) wins in both directions.

Dark mode done right is mostly done early: tokens from day one, roles instead of literals, contrast checked per theme. Retrofitted onto hardcoded colors, it's a month of archaeology. Built on tokens, it's an afternoon — which is the strongest argument for tokens I know.

Found this useful?

Share

I post shorter takes on css, ui & design and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.