Well-made dark mode isn't "set background to black": it's redesigning the surface hierarchy so visual hierarchy survives the change. The tool making it maintainable is CSS variables used as a token system — one set of values, two themes, zero duplication.
The base pattern: semantic tokens
The beginner mistake is conditioning every color directly:
/* FRAGILE: every component repeats the condition */
.card { background: white; }
@media (prefers-color-scheme: dark) { .card { background: #1a1a1a; } }
The correct pattern defines semantic tokens — names describing function, not value:
:root {
--bg-surface: #ffffff;
--bg-page: #f5f5f7;
--text-primary: #111418;
--text-muted: #5c6370;
--border-subtle: rgba(0, 0, 0, 0.08);
--accent: #7c3aed;
}
[data-theme="dark"] {
--bg-surface: #1c1e26;
--bg-page: #101116;
--text-primary: #e8eaed;
--text-muted: #9aa0ab;
--border-subtle: rgba(255, 255, 255, 0.09);
--accent: #9d71ff;
}
And components consume tokens, never literal colors:
.card {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
}
Adding a new theme (high contrast, sepia, seasonal brand) becomes defining another token block. Components don't even notice.
Follow the system AND allow choice
Users expect three options: light, dark, automatic. The standard pattern combines prefers-color-scheme with a stored manual preference:
const stored = localStorage.getItem("theme"); // "light" | "dark" | null
const system = matchMedia("(prefers-color-scheme: dark)").matches;
const theme = stored ?? (system ? "dark" : "light");
document.documentElement.dataset.theme = theme;
// Manual toggle
document.querySelector("#toggle").onclick = () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
localStorage.setItem("theme", next); // null = follow system again
};
For the SYSTEM theme to also work when the user hasn't chosen:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* dark tokens */ }
}
Or simpler: resolve everything in JS on load (snippet above) and keep only [data-theme="dark"] in CSS — less CSS, same UX, at the cost of requiring JS for auto mode.
The white flash: theme FOUC
Carelessly, dark-mode users see a white flash before your JS loads. Classic fix: minimal inline script in <head>, BEFORE any render:
<script>
(function(){
var t = localStorage.getItem("theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.dataset.theme = t;
})();
</script>
Five synchronous lines eliminating the flash entirely. Next.js wraps this in next-themes, which also manages hydration without mismatch.
Designing dark ≠ inverting colors
Dark-theme-specific rules separating professional results from auto-inverted ones:
- Never pure black (#000): creates halos on OLED and brutal contrasts. Desaturated dark surfaces (#101116 → #1c1e26) create depth.
- Invert the elevation hierarchy: in light mode, "higher" = whiter; in dark, "higher" = lighter than background.
- Reduce intensity, not saturation: vivid colors vibrate over dark backgrounds. Lower luminance or raise accent lightness (#7c3aed → #9d71ff).
- Nearly invisible shadows: barely visible over dark backgrounds; partially replace with subtle borders (
border-subtle). - Images and logos: audit black-on-transparent assets; many sites serve per-theme SVG variants.
If designing palettes from scratch, build light tokens first and derive darks by adjusting lightness — our palette generator gives harmonious bases for both worlds, and the color spaces guide explains why OKLCH eases those perceptual derivations.
Smooth theme transition
A global color transition avoids the dry switch:
html.theme-transition,
html.theme-transition *,
html.theme-transition *::before,
html.theme-transition *::after {
transition: background-color .3s ease, border-color .3s ease, color .2s ease !important;
}
Apply the class only during toggle (add-class → switch theme → remove after 300ms): keeping it permanent penalizes scroll performance by animation rules.
FAQ
Should I respect prefers-color-scheme if my brand is light? Recommended: default to auto and let users choose. Forcing light against the system is the number-one complaint on big apps.
Do variables work everywhere? Universal support since 2017 (IE11 out). For extreme legacy, compile static fallbacks with PostCSS.
How do I test both themes fast? DevTools → Rendering → Emulate CSS media feature prefers-color-scheme. Combined with your manual toggle it covers all three states.
Build coherent palettes for both themes with our palette generator, free and right in your browser.