Dark mode
Dark mode is a second design, not an inverted one. The mechanics are simple; the craft is in the handoff between system preference, user override, and first paint.
Method
- Three-state preference: light, dark, system. Default to system via
prefers-color-scheme; store an explicit user choice (localStorage) only when they pick one, and keep following the OS when they chose "system". Listen forchangeon the media query so the app flips live at sunset. - Kill the flash at first paint. A tiny inline script in
<head>reads the stored choice and stampsdata-themeon<html>before any CSS applies. Also declarecolor-scheme: light darkso scrollbars, inputs, and default backgrounds render natively correct. - Retune, do not invert. Dark surfaces are dark gray (not black), elevation becomes lighter-surface instead of shadow, saturated brand colors need desaturating or lightening to hold contrast on dark. Run the contrast pass separately for each theme; 4.5:1 failures cluster in dark mode's muted text.
- Route it through tokens. Only semantic custom properties change
per theme (see css-theming); component CSS never mentions the theme.
If you find
[data-theme="dark"] .cardin a component file, the token layer is missing a role. - Handle images and media. Dim photos slightly
(
filter: brightness(.9)) if they glare; swap logos with<picture media="(prefers-color-scheme: dark)">or a masked SVG usingcurrentColor; keepcolor-schemeoff embedded iframes you do not control. - Test the forgotten surfaces. Focus rings, selection color, placeholder text, disabled states, charts, and email/PDF exports; each has a default that betrays the theme.
Boundaries
- Auto-darkening user-generated content (documents, embedded HTML) can destroy meaning; scope the theme to your chrome and leave content opt-in.
forced-colors: active(Windows High Contrast) overrides both your themes; respect it rather than fighting it.- A dark mode toggle in a cookie-consent-style banner is noise; put it in settings and remember it.