CSS
Stop the White Flash Before a Dark Theme Paints
Set the dark class before the page paints, keep form controls in the same color scheme, and avoid a light frame on every load.
- CSS
- Next.js
- Theme
- Accessibility
On this page
A dark site that flashes white is applying the theme too late. The browser paints the default background, React hydrates, and only then does your effect add a dark class. That gap is one frame on a fast machine and a hard blink on a phone. The class has to be on the document element before the first paint.
There are two honest setups. A site that is always dark can put the class in the server HTML and stop there. A site that remembers a visitor's choice has to read that choice in a tiny blocking script, still before paint, because the server does not know what this browser saved last time.
A dark-only site is a class on html
If you do not offer a light theme, render class="dark" from the root layout and set color-scheme so native controls match. Form fields, scrollbars, and the canvas behind the page then start dark. No script, no localStorage, no flash. This is the right design for a publication that has chosen one look.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body>{children}</body>
</html>
);
}
:root {
color-scheme: dark;
background: #0b0d12;
color: #f4f1ea;
}
A saved choice needs a blocking script
When a toggle exists, the visitor's last choice lives in localStorage under a key you pick, such as theme. The server render can default to dark. A short script then corrects the class if this browser saved light. It has to run as classic blocking JavaScript in the document, before the body content paints. An effect that runs after hydration is what causes the flash you are trying to remove.
<html lang="en" className="dark" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme");var dark=t?t==="dark":true;document.documentElement.classList.toggle("dark",dark);document.documentElement.style.colorScheme=dark?"dark":"light";}catch(e){}})();`,
}}
/>
</head>
<body>{children}</body>
</html>
suppressHydrationWarning on html is there because the script may change the class before React hydrates. React would otherwise complain that the server HTML and the client DOM disagree. The warning is suppressed on that element only. The rest of the tree should still match.
Write the choice when the visitor toggles
The toggle button updates the class and writes the same key the script reads. Next load, the script applies it before paint. Use a button, not a div, and tell assistive tech the current state with aria-pressed.
export function setTheme(theme: "dark" | "light") {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
localStorage.setItem("theme", theme);
}
- Default the script to dark if the key is missing, when dark is the site default. Reading prefers-color-scheme is the other valid default. Pick one and use it in both the script and the server class.
- Keep the script tiny. A framework bundled into that script delays the paint you are trying to protect.
- Set background on html or body in CSS, not only on a wrapper div. The flash is often the document background, not your card.
- Check with the network slowed and cache disabled. The blink shows up when CSS arrives late and the document color is still the browser default.
Dark still needs readable contrast
A navy page with gray body text looks sophisticated in a screenshot and fails a reader in daylight on a phone. Pick a text color that stays clearly lighter than the background, and a muted color that is still readable for secondary lines. Links need a visible underline or another cue besides color. The theme script can be perfect and the page can still be hard to read if the palette is a set of near-black grays.
