The flash of the wrong theme is one of those bugs that looks trivial and has an unavoidably slightly ugly solution. Understanding why makes it easier to accept.
Why it happens
The user's theme choice is in localStorage. The server rendering your HTML cannot read localStorage. So the server has to guess, and the correction happens once React hydrates — by which point the browser has already painted.
No amount of CSS fixes this, because the CSS is correct. The HTML it's applied to is wrong for a few hundred milliseconds.
The only fix
Something has to read storage and set the class on <html> *before first paint*. The only thing that can do that is a blocking inline script in the document head.
<script>
try {
const stored = localStorage.getItem("theme");
const system = matchMedia("(prefers-color-scheme: dark)").matches;
if (stored === "dark" || (stored !== "light" && system)) {
document.documentElement.classList.add("dark");
}
} catch {}
</script>Yes, a blocking script. It's a handful of bytes with no network cost, and it's the one case where blocking is the correct trade. The try/catch matters — localStorage throws outright in some privacy modes, and an exception here leaves the page unstyled.
In Next.js, next-themes injects exactly this, which is most of why it's worth the dependency.
Three states, not two
The mistake in most implementations is treating theme as a boolean. There are three states: explicitly light, explicitly dark, and *follow the system* — and the third is the correct default.
The distinction is real. If someone picks "system", their site should change when their OS switches at sunset. A boolean can't express that, and storing the resolved value instead of the preference silently breaks it.
Getting the dark palette right
A dark theme is not an inversion. Two things consistently go wrong:
Pure black backgrounds with pure white text are too much contrast — it produces halation, where text appears to smear. Near-black and near-white read better.
And saturated colours that worked on white will vibrate on dark. Accent colours generally need to be lighter and less saturated. Working in oklch helps here, because adjusting lightness doesn't shift the hue the way it does in HSL.
Don't forget color-scheme: dark, which fixes form controls and scrollbars that would otherwise stay stubbornly light.