Start with tokens, not colours
Dark mode is painful to retrofit and trivial to build in — and the difference is entirely whether your colours are named. If your stylesheet is full of raw hex values, supporting a second theme means finding and overriding every one. If it references tokens, a theme is just a different set of values for the same names.
Define your palette once as custom properties on :root, then reference only those tokens everywhere else. Name them by role, not appearance: --surface and --text-primary survive a theme switch, while --light-grey becomes a lie the moment dark mode ships.
Respect the system preference first
Most operating systems already have a light/dark setting, and visitors expect sites to honour it. The prefers-color-scheme media query reads it, and supporting it should be your default behaviour — a toggle is an override, not the primary mechanism.
Also set the color-scheme property. It tells the browser to render native controls — scrollbars, form inputs, date pickers — in the matching theme. Without it you get dark pages with jarring white scrollbars and light dropdowns, a detail covered in our themed scrollbars lesson.
Adding a toggle that persists
The standard approach is a data-theme attribute on the root element that overrides the media query, with the choice saved in localStorage. Three pieces: read the stored value on load, apply it as an attribute, and write the new value when the user toggles.
Offer three states if you can — light, dark, and "system" — with system as the default. A toggle that permanently overrides the OS preference is worse than no toggle for people who switch automatically at sunset.
The flash of wrong theme
Here's the problem nobody mentions until it happens: if you apply the saved theme in a script at the end of the body, the page renders in the default theme first and then flips. On a dark-mode preference that's a bright white flash — genuinely unpleasant in a dark room.
The fix is a small blocking inline script in the <head>, before any CSS is applied, that reads localStorage and sets the attribute on documentElement immediately. It's one of the rare legitimate uses of a render-blocking script — it must run before first paint, and it's only a few lines.
Dark mode is not inverted light mode
Several design rules change in the dark. Avoid pure black backgrounds — pure white text on pure black causes halation, where letters appear to smear for many readers, and it's fatiguing. Use a dark grey or navy, around #121212 to #1a1a2e.
Avoid pure white text for the same reason; a light grey at roughly 87% opacity is more comfortable. Desaturate your accent colours, because saturated colours vibrate uncomfortably against dark backgrounds. And remember that shadows barely register in dark mode — convey elevation with lighter surface colours or subtle top-edge highlights instead, which is exactly what this site does.
Check contrast again
Your light-mode contrast results tell you nothing about dark mode. A grey that passes AA on white will usually fail on a dark background, and it's a common oversight because the design "looks fine" to whoever built it.
Run every text-on-surface pairing through the contrast checker in both themes. Also test images and illustrations — logos with white backgrounds and screenshots of light interfaces look broken in dark mode, and often need a variant or a subtle background treatment.
Our palette generator produces the tint and shade scale that makes maintaining two themes straightforward: the same hue at different lightness levels, exported as tokens.