Dark Mode End to End: OS Preference, Toggle, and No Flash of White
Dark mode demos are five lines. Dark mode done - system preference respected, user override that persists, zero wrong-theme flash on load, native controls matching - is a small system spanning all three languages. Here's the whole wiring, the way I ship it.
1. CSS: variables + a data attribute
:root {
color-scheme: light dark;
--bg: #ffffff; --ink: #0f172a; --surface: #f1f5f9;
}
:root[data-theme="dark"] {
--bg: #0b1220; --ink: #e2e8f0; --surface: #16213a;
}
/* System-dark default, ONLY when the user hasn't chosen: */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg: #0b1220; --ink: #e2e8f0; --surface: #16213a;
}
}
body { background: var(--bg); color: var(--ink); }
Three states, one attribute: absent = follow the OS, "light"/"dark" = explicit user choice. color-scheme makes scrollbars, form controls and date pickers follow along (the light-dark() function can slim the variable blocks further on new projects).
2. The anti-flash script - placement is the feature
<head>
...
<script>
(function () {
var t = localStorage.getItem('theme');
if (t) document.documentElement.dataset.theme = t;
})();
</script>
<link rel="stylesheet" href="/css/theme.css">
</head>
Inline, in head, before the stylesheet: the attribute exists before first paint, so a dark-mode user never sees a white flash. This script being async, deferred, or in an external file is the classic dark mode bug - the entire trick is running before render.
3. The toggle: three states, not two
function cycleTheme() {
const cur = localStorage.getItem('theme'); // null -> 'light' -> 'dark' -> null
const next = cur === null ? 'light' : cur === 'light' ? 'dark' : null;
if (next) {
localStorage.setItem('theme', next);
document.documentElement.dataset.theme = next;
} else {
localStorage.removeItem('theme'); // back to "follow system"
delete document.documentElement.dataset.theme;
}
document.cookie = 'theme=' + (next ?? '') + ';path=/;max-age=31536000;SameSite=Lax';
}
// Follow live OS changes while in auto mode
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!localStorage.getItem('theme')) { /* re-render icon state if needed */ }
});
The "auto" state matters: a user whose OS goes dark at sunset wants your site following along unless they said otherwise - two-state toggles quietly steal that behavior. (matchMedia listeners keep it live; wrap the flip in a view transition if you're feeling fancy.)
4. The PHP cookie: server-rendered pages start correct
$theme = $_COOKIE['theme'] ?? '';
$attr = in_array($theme, ['light', 'dark'], true) ? " data-theme=\"$theme\"" : '';
echo "<html lang=\"en\"{$attr}>";
localStorage handles the client; the cookie mirror lets PHP emit the right attribute in the HTML itself - explicit choosers get their theme with zero script execution, page caches permitting (vary cached pages on the cookie, or let the inline script cover cached-page corrections). Belt, braces.
Finish line checklist: sweep hardcoded colors into variables (the actual labor), swap shadows for borders in dark surfaces, dim images slightly ([data-theme="dark"] img { filter: brightness(0.92); }) if your content skews bright, and test both themes with print styles forced light. It's a system - but it's a system you build once and keep forever.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.