Dark mode on the web has one stubborn enemy: the flash. You load a page, it renders in light mode for a split second, then jumps to dark. Even with prefers-color-scheme media queries, the flash shows up on hard refreshes or when the system theme doesn't match what the user last chose on your site.
Here's how this site handles it with Tailwind v4 and next-themes, with zero flash.
Why the flash happens
The flash happens because theme preference is stored in localStorage, and JavaScript runs after the initial HTML paint. By the time your React app reads localStorage and applies the theme class to <html>, the browser has already painted once with the default styles.
The fix is to run a tiny script before the first paint — in the <head>, before any stylesheets or framework code.
How next-themes solves it
next-themes injects an inline <script> tag into the <head> that reads localStorage synchronously and applies the theme class before the browser paints. This is the only reliable way to prevent the flash without server-side session storage.
// app/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
</ThemeProvider>
)
}
The attribute="class" tells next-themes to toggle a class on <html> rather than a data- attribute. This is what Tailwind v4 expects.
The suppressHydrationWarning on the <html> element in the root layout is required — the server renders without a theme class, and the client adds one immediately via the inline script. Without it, React logs a hydration mismatch warning.
// app/layout.tsx
<html lang="en" suppressHydrationWarning>
Tailwind v4's CSS variable system
Tailwind v4 moves away from tailwind.config.js and toward CSS variables defined directly in your stylesheet. Theme colors are defined in a @theme block and referenced with var():
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
}
.dark {
--background: oklch(0.1 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.585 0.233 278.4); /* indigo-500 */
}
When next-themes adds the dark class to <html>, the .dark CSS variables take effect immediately — no JavaScript required on the client for the color swap itself.
The custom variant
Tailwind v4 requires a custom variant for dark mode when you're using the class strategy:
@custom-variant dark (&:is(.dark *));
This tells Tailwind that dark: prefixed utilities should apply when the element is a descendant of .dark. Without this, dark: variants won't work with class-based theming.
Avoiding FOUC during development
In development, Next.js's Fast Refresh can sometimes cause a brief theme flash when hot-reloading. This is a dev-only issue and doesn't affect production. If it bothers you, the disableTransitionOnChange prop on ThemeProvider prevents CSS transitions from firing during theme switches, which makes the flash less visually jarring:
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
Testing it
The reliable way to test: open your site in an incognito window, then inspect the <head> in DevTools before React hydrates. The inline script from next-themes should already be there, and the theme class should already be on <html>. If you see a flash, the script isn't running early enough.
You can also check by throttling JavaScript execution in DevTools — with JS disabled, the page should still render in the correct theme (assuming you set a sensible defaultTheme).
That's the whole setup. Roughly 20 lines of configuration total, and no flash.