Errors and fixes
Dark mode without a flash: settle the theme before the first paint
The white flash comes from applying the theme after React mounts. Decide the class before paint, with a blocking script or a cookie read on the server.
Key points
- The theme must reach the root element before the browser paints: a React effect is always too late.
- A synchronous inline script before the CSS, or a cookie read on the server, removes the flash at the root.
- `color-scheme` tells the browser how to paint scrollbars and native controls; without it the theme is half done.
Short answer: decide the theme before the first paint. A synchronous inline script in the <head> that writes the class on the root element kills the flash; a React effect always arrives later.
Why the inline script wins the race
The browser builds the DOM, then computes style, then paints. A synchronous script in the <head> runs during the build, before style computation: by the time the CSS arrives the class is already on the root element. A React effect, by contrast, is queued after the commit, when the first frame has already been presented. The difference is not speed, it is order.
There is a second channel, often forgotten: the browser paints the canvas background before any rule. color-scheme, declared as a property or as a meta tag, tells the user agent which background to use, so even the first frame with no CSS is already dark. Without it the script fixes the text and the page stays light for one frame.
The flash, explained
- The browser receives the HTML and paints the default light background.
- React downloads, runs and mounts.
- An effect reads the preference and adds the
darkclass. - The browser repaints: the switch from white to black is the flash.
Every step after the first paint is too late. The fix has to land before the paint, which means inside the HTML.
The fix: a blocking script
try {
var stored = localStorage.getItem("theme");
var dark = stored
? stored === "dark"
: matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
The script is synchronous and sits in the <head>, before the CSS. The try block covers browsers that block localStorage in private mode: without it, one error stops the rest of the page.
The server variant
When the theme is a user setting, store it in a cookie and read it on the server: the first byte of HTML already carries the right class. This is the sturdier route because it does not depend on JavaScript.
With Next.js, React and other frameworks the server and client values can disagree if the user changes theme. Put suppressHydrationWarning on the <html> element only, the single place where the difference is intended.
The diagnostic step
Do not check this by eye on a fast machine. Open the developer tools, Performance tab, enable Screenshots and record a load with the cache disabled and the network throttled. Walk the frame strip: the flash is a light frame followed by a dark one, and its position tells you whether the script ran after the first paint.
Alternatively, read two values in the console right after a clean load.
console.log(document.documentElement.className || "(nessuna classe)");
console.log(getComputedStyle(document.documentElement).colorScheme);
If the class is already there on the first frame and the background is still light, color-scheme is missing. If the class arrives later, the script is not in the <head> or the policy blocks it.
When the fix does not work
If the Content Security Policy blocks the inline script, the theme still arrives late: you need a nonce or a hash in the policy. A second case is CSS that uses @media (prefers-color-scheme: dark) and the .dark class together: the two rules override each other and the result depends on order. Pick one source of truth.
When the common fix makes things worse
The most common shortcut hides the page until React has mounted, with visibility: hidden on the body and an effect that brings it back. The flash disappears and the cost moves to what Google measures: Largest Contentful Paint starts from the first paint of the content, so the page looks slower exactly where it used to be fast. With JavaScript off, an empty document is all that is left.
A subtler variant puts suppressHydrationWarning on the <html> element and lets the client correct the class: the warning leaves the logs and the second theme switch stays, because the correction happens after hydration. Silencing the error does not change the order of the paints.
One limitation to budget for
The cookie variant works only when the HTML is generated for that request. Behind a CDN with static caching the first visitor fills the cache with their theme and everyone else receives it: the class comes from the server, so there is no flash, but it is the wrong theme. You need Vary: Cookie or a page rendered at the edge; without it, go back to the inline script and accept its cost.
How to prevent it
- One mechanism only: a class on the root element, never a media query and a class together.
- Always set
color-scheme: scrollbars and native controls follow the theme. - Record the first frames with an empty cache to confirm the flash is gone.
Where to continue
In the catalogue: themes, components and hero block, plus the blocks index. The guides: dark mode without a second design system and accessibility checklist; the comparison: free and premium themes.
Frequently asked questions
Why is a React effect not enough?
Effects run after the first paint. By then the browser has already painted the page in the light theme.
Does the inline script break CSP?
Yes, if the policy forbids inline scripts. You then need a nonce or a hash, and that is a real cost to budget for.
Cookie or localStorage?
With SSR the cookie is simpler, because the server can write the right class into the HTML. In a pure SPA, localStorage is enough.
What does color-scheme do?
It tells the browser which colours to use for native controls, scrollbars and the default background. Without it, controls stay light even in dark mode.
How do I test the flash?
Record at 60 fps with the cache cleared and watch the first frames. An automated test can compare the background colour before hydration.