Fixing the Flash of Wrong Theme on Page Load When Using Client-Side Dark Mode Switching
Dark mode works perfectly once the page loads, but every hard refresh shows a split-second flash of the light theme first. It's a timing problem, not a logic bug — here's the definitive fix with a blocking inline script.
The Problem
A site with light/dark mode support (using a library like next-themes, or a custom localStorage-based theme switch) works perfectly once loaded — but on every hard refresh, users on dark mode see a split-second flash of the light theme before it switches to dark. It's not a bug in the theme logic itself; it's a fundamental ordering problem between when the browser paints and when JavaScript runs.
Why It Happens
The theme decision lives in JavaScript, which runs after the first paint
A typical client-side theme switcher reads the user's saved preference from localStorage (or prefers-color-scheme) inside a React useEffect or similar lifecycle hook. But the browser has already parsed the initial HTML and applied the default (usually light) styles and painted them to the screen before any JavaScript — including that effect — has a chance to run. The theme "switch" the user sees is real; it's just happening after an already-visible flash of the wrong theme.
Server-rendered HTML has no way to know the client's saved preference
If the site is server-rendered or statically generated, the server genuinely doesn't know which theme a given visitor saved last time — that information only exists in the browser's localStorage. Any theme applied during server rendering is a guess (usually a fixed default), and the client-side correction that follows is exactly what causes the flash.
The Fix
1. Apply the theme class before the framework's JavaScript even loads
<script>
(function () {
var saved = localStorage.getItem("theme");
var theme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
})();
</script>
Place this as an inline, synchronous script in the document <head>, before any stylesheet or app bundle. Because it's inline and blocking, it runs and sets the theme attribute before the browser paints anything, so there's no flash to fix in the first place — the correct theme is applied from the very first frame.
2. Style off the attribute the inline script sets, not a class added later by React
:root[data-theme="dark"] {
--bg: #0a0a0a;
--fg: #ffffff;
}
The CSS needs to key off the same attribute the blocking script sets, so there's no gap between "the script ran" and "the styles that depend on it are active."
3. Suppress the hydration warning this technique causes, deliberately
Setting an attribute on <html> before React hydrates will mismatch what React expects to render, producing a hydration warning. Libraries like next-themes handle this internally; if rolling this by hand, add suppressHydrationWarning on the root element specifically for that attribute — this is a known, safe exception, not a real bug to chase down.
4. Keep the inline script minimal and dependency-free
It must run before anything else, so it can't import from the app bundle or rely on any library being loaded yet — keep it to plain, synchronous JavaScript that only touches localStorage, matchMedia, and a DOM attribute.
Why This Works
The flash isn't caused by the theme logic being wrong — it's caused by the correct theme being applied too late, after the browser already painted a default. Moving that decision into a blocking inline script that runs before paint removes the gap entirely, rather than trying to make the "wrong theme → right theme" transition less noticeable.
Conclusion
A flash of the wrong theme on load is a timing problem, not a logic problem — client-side theme detection in a useEffect always runs after the first paint. Move the theme decision into a small blocking inline script in the document head, style off the attribute it sets, and the correct theme is there from the very first frame with nothing to flash.