Dark mode looks simple — invert the colours, done. Real dark mode is harder: contrast ratios change, brand colours shift, photos need different treatment, and the flash from light to dark on load reveals every implementation flaw. This guide walks through dark mode done right: design tokens, accessible contrast in both modes, image handling, persistence, and no-flash loading.
Hard-coded colours can't switch modes. Replace every colour value with a CSS variable that has light AND dark definitions.
:root {
/* Light mode defaults */
--bg: #ffffff;
--bg-elevated: #f8fafc;
--text: #0f172a;
--text-muted: #475569;
--border: #e2e8f0;
--accent: #7c3aed;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--bg-elevated: #1e293b;
--text: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--accent: #a78bfa; /* Lighter accent for dark mode */
}
}
/* All components use tokens, never raw colours */
body { background: var(--bg); color: var(--text); }
.card { background: var(--bg-elevated); border: 1px solid var(--border); }
.muted { color: var(--text-muted); }
This is the core pattern. Everything else builds on it.
--bg: #0f172a; /* Off-black, not pure black */ --bg-elevated: #1e293b; /* Slightly lighter for cards */ --text: #f1f5f9; /* Off-white for body text */ --text-muted: #94a3b8; /* Soft grey for secondary */ --border: #334155; /* Visible but not loud */
Vibrant brand colours often fail contrast in dark mode. Define dark-mode variants:
--accent: #7c3aed; /* Light mode — passes 4.5:1 on white */
@media (prefers-color-scheme: dark) {
:root {
--accent: #a78bfa; /* Lighter variant — passes 4.5:1 on dark */
}
}
Dark mode pairs need their own WCAG verification. Common failure: dim grey text on dark background fails 4.5:1.
--text on --bg: should be 4.5:1+--text-muted on --bg: 4.5:1+ for body text, 3:1+ for large--text on --bg-elevated: same checks for card surfaces--accent on --bg: 4.5:1+ for links<!-- Use currentColor so logo adapts to text colour --> <svg fill="currentColor"> <path d="..."/> </svg>
The SVG inherits the text colour, which already changes with the theme. No separate dark logo needed.
<picture>
<source srcset="/logo-dark.png"
media="(prefers-color-scheme: dark)">
<img src="/logo-light.png" alt="Brand">
</picture>
Most photos work in both modes. Heavily-shadowed dark photos can look "blown out" on light backgrounds; very bright photos can be glaring on dark backgrounds. Test specific images; provide alternatives only for problem cases.
Need dark variants. Or convert to SVG with currentColor.
Default to OS preference, allow user override. Three-state toggle works best: System, Light, Dark.
// Markup
<button id="theme-toggle" aria-label="Toggle theme">
<span class="theme-icon"></span>
</button>
// JS — apply chosen theme via data attribute
const stored = localStorage.getItem('theme') || 'system';
applyTheme(stored);
function applyTheme(mode) {
if (mode === 'system') {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', mode);
}
localStorage.setItem('theme', mode);
}
document.getElementById('theme-toggle').addEventListener('click', () => {
const current = localStorage.getItem('theme') || 'system';
const next = current === 'system' ? 'light'
: current === 'light' ? 'dark'
: 'system';
applyTheme(next);
});
:root { /* light defaults */ }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
/* dark values — applies when OS is dark AND user hasn't overridden to light */
}
}
:root[data-theme="dark"] {
/* dark values — applies when user explicitly chose dark */
}
<head>
<script>
// INLINE in head — runs before any render
(function() {
const stored = localStorage.getItem('theme');
if (stored === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (stored === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
// 'system' or null: let prefers-color-scheme media query work
})();
</script>
<link rel="stylesheet" href="/main.css">
</head>
This blocking script runs before CSS parses, so the data-theme attribute is set before any styles apply. No flash.