Dark Mode in Next.js and Tailwind v4 Without the Flash of Wrong Theme
The theme flash is a rendering-order problem, not a CSS one. Here is the blocking-script pattern that fixes it in a static Next.js app, with the full code.
The flash of the wrong theme happens because your HTML reaches the browser before your JavaScript decides which theme to apply. The fix is a small synchronous script in head that reads the saved preference and sets a class on html before the first paint. It cannot be solved in CSS or in a React effect, because both run too late.
Here is the whole pattern, plus the parts people usually get wrong.
Why the flash happens
Walk through what the browser does with a statically rendered page:
- HTML arrives.
htmlhas nodarkclass, so light styles apply. - The browser paints. The user sees a white screen.
- React hydrates.
useEffectruns, readslocalStorage, adds.dark.- The browser repaints dark.
Steps 2 and 5 are separated by tens to hundreds of milliseconds. That gap is the flash.
useEffect is the wrong tool by design — it runs after paint, always. Moving the logic to useLayoutEffect does not help either: on a server-rendered page it still runs after hydration, which is after the first paint. The decision has to happen before the browser paints at all, and the only thing that runs that early is a blocking script.
The fix
Inject a synchronous script into head from your root layout:
// app/layout.tsx
const themeScript = `
(function () {
try {
var stored = localStorage.getItem("theme");
if (stored === "dark") {
document.documentElement.classList.add("dark");
}
} catch (e) {}
})();
`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
);
}
Four details in there matter:
No async or defer. The blocking behaviour is the entire point. This script must execute before the parser continues to body.
try/catch around localStorage. Access throws in Safari private browsing and when cookies are blocked entirely. An uncaught throw here happens before anything else on the page and takes the whole app down.
suppressHydrationWarning on html. The script mutates html's class list before React hydrates, so the server HTML and the client DOM legitimately disagree. This attribute tells React that one element's attributes are expected to differ. It applies to that element only — it does not silence real hydration bugs elsewhere in your tree.
Use <script> directly, not next/script. next/script with the default strategy loads after hydration, which reintroduces the flash. This needs to be a plain inline script in head.
Wire it to Tailwind v4
Tailwind v4 dropped darkMode: "class" along with the JavaScript config. Declare the variant in CSS instead:
/* app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Without that line, dark: follows prefers-color-scheme and your toggle silently does nothing while the OS setting quietly wins. It is one of the more confusing v4 migration failures because there is no error.
The :where() wrapper keeps specificity at zero, so dark: variants stay as easy to override as their light counterparts.
The toggle
"use client";
import { useEffect, useState } from "react";
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
// Read the class the blocking script already set, rather than reading
// localStorage again — the DOM is now the source of truth for the
// current theme, and this keeps the two from disagreeing.
useEffect(() => {
setIsDark(document.documentElement.classList.contains("dark"));
}, []);
function toggle() {
const next = !isDark;
setIsDark(next);
document.documentElement.classList.toggle("dark", next);
try {
localStorage.setItem("theme", next ? "dark" : "light");
} catch (e) {
// Storage unavailable — the theme still applies for this session.
}
}
return (
<button
type="button"
onClick={toggle}
aria-pressed={isDark}
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<span className="sr-only">
{isDark ? "Switch to light theme" : "Switch to dark theme"}
</span>
{isDark ? "☀" : "☾"}
</button>
);
}
The button starts at false on the server and corrects itself in the effect. That is fine — the page is already correct from the blocking script; only the toggle's own icon settles a moment later. Rendering the wrong icon for one frame is invisible. Rendering the wrong page is not.
Should you follow the system preference?
Two defensible policies, and the choice is a product decision rather than a technical one:
System-first. Respect prefers-color-scheme unless the user has explicitly chosen otherwise.
var stored = localStorage.getItem("theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (stored === "dark" || (!stored && prefersDark)) {
document.documentElement.classList.add("dark");
}
Light-first. Default to light and only go dark on an explicit saved choice — the shorter script at the top of this article.
System-first is the better default for most products. Light-first is the right call when your marketing pages are designed light and your dark theme is a genuine alternate rather than a full mirror; it guarantees first-time visitors see the design you actually art-directed. Either way, an explicit user choice must win over the OS.
Verifying it is actually fixed
Reading "no flash" off your own fast machine proves very little. Two checks that do:
Throttle the CPU. Chrome DevTools → Performance → CPU: 6x slowdown, then hard-reload. The flash gets proportionally longer and becomes obvious.
Record the paint. Performance panel → reload → look at the screenshot filmstrip. The very first painted frame should already be dark. If frame one is white and frame four is dark, the script is not blocking — check that it is inside head and carries no defer.
Common causes when it still flashes
| Symptom | Cause |
|---|---|
| Flash persists on reload | Script is in body, or uses next/script |
| Toggle does nothing | @custom-variant dark missing in v4 |
| Hydration warning in console | suppressHydrationWarning missing on html |
| Blank page in Safari private mode | localStorage access not wrapped in try/catch |
| Correct on first load, wrong after navigation | Class set on body instead of html |
That last row catches people migrating from older setups. Client-side navigation does not re-run the blocking script, and some libraries replace body attributes on route change. Set the class on document.documentElement and it survives every navigation.
Frequently asked questions
Does this hurt performance? The script is roughly 200 bytes and executes in well under a millisecond. It blocks the parser for that time, which is exactly the trade you want — a sub-millisecond delay in exchange for never painting the wrong theme.
Does it work with static export?
Yes. It is plain HTML and inline JavaScript, so it works identically with output: "export", on a CDN, or behind any host.
What about a strict Content-Security-Policy?
An inline script needs either 'unsafe-inline' or a nonce on script-src. Nonces force dynamic rendering on every route in Next.js, which defeats static generation — so most statically generated marketing sites accept 'unsafe-inline' here and document the trade-off. If your CSP must be nonce-based, pass the nonce through from middleware and accept the dynamic rendering cost.
Can I avoid the inline script entirely? Only by rendering the page per-request and reading a theme cookie server-side. That works and is genuinely flash-free, but it makes every page dynamic. For a static site the blocking script is the cheaper trade.
Templates with this already wired
Every one of our Next.js admin templates ships this pattern — blocking script, @custom-variant, and dark variants on every component, so you inherit a working theme toggle rather than retrofitting one. ASoc Admin and ASoc Lura both use exactly the code above.
