Dark Mode Without the Flash
You’ve seen it. You click a link, the page paints in stark white, and a quarter-second later it slams to dark. Your retinas notice. Your trust in the site drops a notch. Somewhere, a developer is shipping a useEffect that reads localStorage and calls setState, and the FOUC is the visible cost.
This is a solved problem. It has been for years. The fix doesn’t require a framework, a context provider, or a hook. It requires understanding the order of events in a page load and putting one small synchronous script in the right place.
The Three Pieces
A flash-free theme switch needs exactly three things:
- CSS variables keyed off a single attribute on
<html>—data-theme="dark"or a.darkclass. - A blocking inline script in
<head>that reads the user’s preference and sets that attribute before the browser paints anything. - A toggle UI that updates both
localStorageand the same attribute.
That’s the entire architecture. Everything else — context providers, theme hooks, animation transitions, system-preference subscriptions — is decoration. Get those three right and the FOUC is gone forever.
Why the Flash Happens
The flash isn’t a styling bug. It’s a timing bug.
The browser parses HTML top to bottom. It applies CSS as it goes. It paints the first frame as soon as it has enough to render — usually right after <head> finishes. Then it runs deferred scripts and React hydration.
If your “set the theme” code lives in a React effect, here’s the sequence:
- HTML arrives, CSS applies with default (light) variables → paint #1: white
- React hydrates, runs
useEffect, reads localStorage, callssetState - React re-renders, swaps the class on
<html>→ paint #2: dark
The white frame between #1 and #2 is the flash. It’s not subtle on a slow network. On any network, it’s perceptible at 60fps.
The fix is to set the attribute before paint #1. Which means before the body parses. Which means a synchronous script in <head> — not deferred, not module, not async, not anything React can do.
The Variables
Start with CSS variables on :root and an override on [data-theme="dark"]:
:root {
--bg: #ffffff;
--fg: #111111;
--muted: #666666;
--border: #e5e5e5;
}
[data-theme="dark"] {
--bg: #0a0a0a;
--fg: #f5f5f5;
--muted: #a0a0a0;
--border: #2a2a2a;
}
body {
background: var(--bg);
color: var(--fg);
}
Every component reads from var(--bg), var(--fg), etc. Switching the theme is now a single attribute change on <html>. No re-render, no hydration, no JS — the browser swaps the variable values and repaints.
If you’re on Tailwind v4, you get this for free with @custom-variant and @theme. v3 needs darkMode: ['selector', '[data-theme="dark"]'] in the config.
The Pre-Paint Script
This is the part that can’t be skipped or moved:
<!-- in <head>, BEFORE any stylesheet that depends on theme -->
<script>
(function () {
var stored = localStorage.getItem('theme');
var system = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
var theme = stored === 'dark' || stored === 'light' ? stored : system;
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
Twelve lines. No framework. It runs before the browser paints because it’s synchronous and inline. By the time the first stylesheet applies, <html> already has data-theme="dark" (or light), so the correct variables are used in paint #1. There is no paint #2 to flicker to.
A few details that matter:
- It must be inline. An external
<script src>introduces a network round-trip and breaks the timing. - It must not be
deferortype="module". Both of those wait for the document to parse before running. - It should be the first script in
<head>. Anything earlier risks blocking it; anything later might paint first. - Don’t try to sanitize too hard. A
try/catcharoundlocalStorageis the only defensive code you need — Safari private mode used to throw, though modern versions don’t.
In Next.js, this is a <Script strategy="beforeInteractive"> rendered in the root layout. In Astro, it’s a raw <script is:inline> tag. In a hand-rolled static site, it’s a literal <script> in your template. The mechanism varies; the rule doesn’t.
The Toggle
The toggle is the easy part because the hard work is done. A button updates two things, in this order:
function setTheme(next) {
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
Set the attribute first so the visual change happens immediately, then persist. Reverse the order and a slow localStorage write delays the paint — usually invisible, but free wins are free.
If you want to support a three-way toggle (light / dark / system), store a sentinel:
function setTheme(choice) {
if (choice === 'system') {
localStorage.removeItem('theme');
var system = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', system);
} else {
document.documentElement.setAttribute('data-theme', choice);
localStorage.setItem('theme', choice);
}
}
The pre-paint script already handles the absence of a stored value as “follow system,” so removing the key is enough.
Reacting to System Changes
Users change their OS theme at sunset. If they’ve picked “system” in your toggle, your site should follow without a reload:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.setAttribute(
'data-theme',
e.matches ? 'dark' : 'light'
);
}
});
This goes in your normal app JS, not the pre-paint script. The first load is already correct; this just keeps it in sync if the user has explicitly opted into “follow system.”
What This Replaces
If you’ve ever installed next-themes, theme-ui, or rolled a ThemeProvider with React Context — what you actually needed was the twelve-line script above plus a CSS variable file. The provider gives you useTheme() for components that care, which is fine, but it’s the implementation of the toggle UI, not the prevention of the flash. The flash is prevented by the inline script. The provider isn’t doing that work for you.
A common smell: a provider that does its own useEffect to read localStorage and set a class. That’s the flash bug, just hidden inside node_modules. Check your library’s source — if it doesn’t render an inline script during SSR, it can’t prevent the flash. Some libraries do; many don’t.
The Honest Tradeoffs
Two things worth knowing.
The pre-paint script blocks the parser for a millisecond. That’s fine — it’s smaller than a single CSS rule — but it does count against your time-to-first-paint budget. Don’t put fifty lines of logic in there.
System preference changes during a page session don’t trigger a re-render in React. If you’ve got components that read useTheme() for conditional logic, they need to subscribe to the media query themselves, or you need to bridge the attribute change into React state. For the common case — components reading CSS variables — this doesn’t matter, because the variables update without React.
The Pattern, Compressed
- One attribute on
<html>. - One inline pre-paint script that sets it from
localStorageorprefers-color-scheme. - CSS variables that read from that attribute.
- A toggle that writes to both.
That’s a flash-free dark mode in any framework, on any host, for any size site. It’s not new. It’s not clever. It’s been the right answer since CSS variables shipped, and it will keep being the right answer because it works with the grain of how browsers actually load pages.
If your site flashes white on load, you don’t have a dark-mode bug. You have a script-placement bug. Fix the placement, and the flash dies.