Short version for the impatient: your dark mode flashes white on load because the class that switches the theme gets applied by React, and React runs after the browser has already painted. The fix is four lines of blocking JavaScript in your <head>. Everything else in this post is me explaining why I took an embarrassingly long time to figure that out.
Here’s the actual sequence of events. I shipped a dark mode toggle on a client dashboard in March. Looked great in dev. Two weeks later the client sent me a video, filmed on their phone, of the app loading. Every single page load: a hard white flash, maybe 200 milliseconds, then the dark theme snapped in. They described it as “the website blinking at me.” They weren’t wrong. I’d tested the toggle a hundred times and never once tested a cold page load with dark mode already saved.
So this is the dark mode post I wish I’d read. It covers the Tailwind v4 setup, the flash, the system-preference handling that most tutorials get subtly wrong, and the one CSS property nobody mentions that fixes your scrollbars and form inputs.
The v3 config that doesn’t exist anymore
If you learned Tailwind dark mode before v4, you learned it as a JavaScript config key:
// tailwind.config.js — Tailwind v3
module.exports = {
darkMode: 'class',
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
theme: { extend: {} },
}
That file is optional in v4, and a lot of projects don’t have one at all now. Configuration moved into CSS. I wrote up the broader shift in the CSS-first config I actually ship, but the dark mode piece specifically looks like this:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
That one line replaces darkMode: 'class'. It tells Tailwind that dark: should activate whenever an element sits inside something carrying the .dark class. Without it, v4 defaults to prefers-color-scheme, which means dark: follows the operating system and your toggle button does nothing at all.
I lost about forty minutes to that. I’d migrated a project, deleted the config file because v4 said I could, and then sat there clicking a toggle that had quietly become decorative. The Tailwind dark mode docs do explain this. I just skimmed them, which is a recurring theme in my debugging stories. If you’re mid-migration, I catalogued the rest of the breakage in what actually broke going from v3 to v4.
The toggle everyone ships, including me
Search “tailwind dark mode toggle” and you’ll get roughly this component, in some variation:
function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('theme', dark ? 'dark' : 'light');
}, [dark]);
return (
<button onClick={() => setDark(d => !d)}>
{dark ? 'Light' : 'Dark'}
</button>
);
}
This works. Click it, the page changes. Ship it, and you’ve built the blinking website.
Two separate bugs live in there. The first is that useState(false) means every page load starts in light mode regardless of what the user picked last time, so you need to read localStorage on mount. The obvious patch is to move the read into the effect:
useEffect(() => {
setDark(localStorage.getItem('theme') === 'dark');
}, []);
And that’s the second bug, because it doesn’t fix anything. It just makes the flash correct instead of permanent. The browser parses your HTML, paints a white page, hydrates React, runs your effect, and only then adds .dark to the html element. The user sees the white paint. No amount of restructuring the component gets around it, because the component can’t run before the paint. That’s the whole problem in one sentence, and it took me two weeks and a client video to say it out loud.
The blocking script that actually fixes it
The class has to be on <html> before the browser paints. Which means it has to be set by a synchronous script in the <head>, above everything else:
<script>
(function () {
var stored = localStorage.getItem('theme');
var system = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (stored === null && system)) {
document.documentElement.classList.add('dark');
}
})();
</script>
Yes, this is a render-blocking script, and yes, that’s the point. It’s about 200 bytes and it runs in well under a millisecond. Your analytics snippet costs more.
In Next.js with the App Router, it goes in app/layout.tsx:
export default function RootLayout({ children }) {
const script = `
(function () {
var stored = localStorage.getItem('theme');
var system = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (stored === null && system)) {
document.documentElement.classList.add('dark');
}
})();
`;
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: script }} />
</head>
<body>{children}</body>
</html>
);
}
The suppressHydrationWarning on <html> matters. The script mutates the class attribute before React hydrates, so React sees server markup and client markup disagree and complains in the console. The attribute tells it to skip that check on this one element only.
If you’d rather not hand-roll this, next-themes does exactly the same thing and I use it on most projects. But I think you should write it once yourself, because when the flash comes back in six months you’ll actually know where to look.
System preference, and the trap in the middle
Most implementations treat dark mode as a boolean. Real users have three states: light, dark, and “whatever my laptop is doing.”
The trap is what happens when someone picks a theme explicitly and then changes their OS setting. If you’re storing a boolean, you can’t tell “user chose light” apart from “user hasn’t chosen and their system is light.” So you either ignore the OS change forever, or you override a choice they deliberately made. I’ve shipped both. The second one is worse.
Store the three-state value instead:
const [theme, setTheme] = useState('system'); // 'light' | 'dark' | 'system'
useEffect(() => {
const root = document.documentElement;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
const isDark = theme === 'dark' || (theme === 'system' && media.matches);
root.classList.toggle('dark', isDark);
};
apply();
if (theme === 'system') {
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}
}, [theme]);
Note that the listener only attaches when the user is on system. If they’ve explicitly picked light or dark, OS changes get ignored, which is what “explicitly picked” should mean. And localStorage stores 'system' as a real value, not as absence, so the blocking script needs a small update to check for it.
MDN’s page on prefers-color-scheme is worth reading if you want the details on when that media query fires.
The one property that fixes your scrollbars
Your Tailwind classes only style things Tailwind can reach. Native browser chrome ignores them completely: scrollbars, <select> dropdowns, date pickers, form field autofill, the caret. You get a beautiful dark layout with a bright white scrollbar down the side and date inputs that look like they wandered in from a different website.
One declaration fixes all of it:
@layer base {
:root { color-scheme: light; }
.dark { color-scheme: dark; }
}
color-scheme tells the browser which set of native widget styles to use. It’s a single line and it does more visible work than most of the CSS I write. I ran a dark theme without it for about a year on a side project and just assumed browser scrollbars couldn’t be themed. They can. I was wrong, comprehensively.
Locking it down so it stays fixed
The flash is a regression that’s easy to reintroduce, because it only shows up on a cold load with a stored preference and nobody tests that by hand. So write the test once:
test('dark theme applies before paint', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('theme', 'dark'));
await page.goto('/');
const cls = await page.locator('html').getAttribute('class');
expect(cls).toContain('dark');
});
addInitScript runs before any page script, so localStorage is populated by the time your blocking script reads it. If someone later moves the theme logic into a React effect, this test fails, and it fails for the right reason. I write this kind of guard-rail test for most of the client work I take on, because the bugs that survive longest are the ones that only appear in states developers never manually reproduce.
Do this today
Open your site in a private window, set the theme to dark, close it, and open it again. Watch the first 300 milliseconds. If it blinks, add the blocking script. Then add the color-scheme block and look at your scrollbar.
The whole fix is maybe fifteen lines and it took me an entire client relationship’s worth of embarrassment to learn. You can just have it.