A lot of dark mode implementations get two things wrong: they flash the light theme for a split second before JavaScript applies dark mode (the “flash of incorrect theme”), and they ignore the user’s OS-level preference entirely, forcing everyone to manually toggle it every visit.
Respecting the system preference first
:root {
--bg: #fdfdf9;
--text: #111;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--text: #fdfdf9;
}
}
body {
background: var(--bg);
color: var(--text);
}
This alone handles the majority case with zero JavaScript — someone with dark mode set at the OS level sees a dark site immediately, no flash, no flicker.
Adding a manual override toggle
<html data-theme="">
<script>
(function() {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
})();
</script>
This inline script needs to run in the <head>, before any CSS renders — that’s what actually prevents the flash. Deferring it, or putting it at the bottom of the page, defeats the purpose entirely.
[data-theme="dark"] {
--bg: #111;
--text: #fdfdf9;
}
[data-theme="light"] {
--bg: #fdfdf9;
--text: #111;
}
The explicit data-theme attribute overrides the media query when present, letting the manual toggle take precedence over system preference, while falling back to system preference when no manual choice has been made.
The accessibility part people skip
The toggle button itself needs a proper accessible label that changes with state — aria-label="Switch to light mode" when currently dark, and the reverse when currently light — not a static label that doesn’t reflect what clicking it will actually do. Screen reader users otherwise have no way to know which mode they’re currently in.