Dark modeNext.js
Dark mode in React and Next.js without a flash
Why the wrong theme flashes on load and how to fix it: a blocking script before paint, the class strategy, system preference, and hydration warnings.
The wrong theme flashes because the page is painted before your JavaScript reads the stored preference. Fix it with a small inline script in <head> that reads the preference and sets a dark class on <html> before the body renders, then add suppressHydrationWarning to <html> so React accepts the changed class.
Why does the wrong theme flash?
With server rendering or static generation, the HTML is produced without access to the visitor's browser. The server cannot read localStorage, so it renders the default theme. A typical first attempt looks like this:
"use client";
import { useEffect } from "react";
export function ThemeLoader() {
useEffect(() => {
// Too late: the page has already been painted.
if (localStorage.getItem("theme") === "dark") {
document.documentElement.classList.add("dark");
}
}, []);
return null;
}The browser receives the HTML, downloads the CSS, and paints the light page. Only then does the JavaScript bundle load, React hydrates, and the effect runs. For a visitor who chose dark mode, that sequence is a white flash on every full page load. Moving the code to useLayoutEffect does not help, because hydration itself happens after first paint.
The fix: set the class before first paint
A classic <script> without async, defer, or type="module" blocks HTML parsing while it runs. Placed in <head>, an inline script runs before the browser reaches the body, so the class is already on <html> when the first frame is painted. Keep it tiny and wrap it in try, because localStorage can throw in some privacy modes and sandboxed frames:
<script>
(function () {
try {
var stored = localStorage.getItem("theme");
var dark =
stored === "dark" ||
((!stored || stored === "system") &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
} catch (e) {}
})();
</script>In a Vite or other single-page app, this goes directly into index.html. In a Next.js App Router project, render it from the root layout. Aback UI ships this exact logic as the ThemeScript component:
import { ThemeScript } from "@/components/ui/theme-toggle";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
</html>
);
}Add it with npx aback-ui@latest add theme-toggle, or import it from @aback-ui/ui if you use the package. It accepts a storageKey and a nonce for a Content Security Policy that does not allow unmarked inline scripts. The setup is documented in the dark mode guide.
Keep the script small
The class strategy and CSS variables
The script can only stay small if the theme is controlled by one switch. Put every colour in a CSS variable, give :root the light values and .dark the dark values, and have components read the variables:
:root {
color-scheme: light;
--background: #ffffff;
--foreground: #0a0a0a;
}
.dark {
color-scheme: dark;
--background: #0a0a0a;
--foreground: #fafafa;
}
body {
background-color: var(--background);
color: var(--foreground);
}The color-scheme property matters too: it tells the browser to draw native form controls and scrollbars in the matching scheme, which a variable cannot do. See color-scheme on MDN (opens in a new tab).
Tailwind CSS v4's dark: variant follows prefers-color-scheme by default. To make it follow your class, redefine the variant. The Aback UI theme does it with one line:
@custom-variant dark (&:is(.dark *));With semantic utilities such as bg-background and text-foreground, most components never need a dark: class at all. The full pattern is in Design tokens in Tailwind CSS v4 with @theme.
Following the system preference
Most visitors never touch a theme toggle, so the default should be their operating system setting. Store three values rather than two:
| Stored value | Meaning | Resolved theme |
|---|---|---|
| (nothing) | No choice made yet | From prefers-color-scheme |
system | Explicitly follow the OS | From prefers-color-scheme |
light | Explicit choice | Light |
dark | Explicit choice | Dark |
The inline script handles the first paint. After that, listen for changes so the page follows the OS when it switches at sunset, but only while the stored value is system:
const media = window.matchMedia("(prefers-color-scheme: dark)");
media.addEventListener("change", (event) => {
const stored = localStorage.getItem("theme");
if (stored && stored !== "system") return;
document.documentElement.classList.toggle("dark", event.matches);
});Aback UI's useTheme hook does this while any component using it is mounted. It also listens for the storage event, so a change in one tab is applied in the others.
Avoiding hydration warnings
Two separate problems produce hydration warnings in themed apps.
The class on html. The inline script changes the class attribute before React hydrates, so the DOM no longer matches the server HTML. Adding suppressHydrationWarning to <html> tells React to accept the difference. As the React documentation (opens in a new tab) notes, it only works one level deep: it covers that element's attributes, not mismatches in its children.
Theme-dependent rendering. If a component renders a sun icon when the theme is dark and a moon when it is light, the server picks one and the client may pick the other. Avoid the branch in JavaScript and let CSS choose:
<button type="button" aria-label="Toggle theme">
<MoonIcon aria-hidden="true" className="dark:hidden" />
<SunIcon aria-hidden="true" className="hidden dark:block" />
</button>Both icons are in the server HTML, and the dark class set by the script decides which one is visible, so the toggle is correct before hydration. If you need the theme as a value in JavaScript, read it with useSyncExternalStore and give it a stable server snapshot. useTheme returns light as resolvedTheme during the server render, so use that value only for things that can update after hydration.
Building the toggle
A theme toggle is a small control with a few accessibility requirements:
- It is a
<button type="button">, not a clickablediv. - An icon-only button needs an accessible name. A name that says what will happen, such as "Switch to dark theme", is clearer than a bare "Theme".
- The icons are decorative, so they carry
aria-hidden. - The focus indicator must be visible in both themes. Check it with the contrast checker; WCAG asks for 3:1 against adjacent colours.
The ThemeToggle component follows these rules and lets you rename its labels with toDarkLabel and toLightLabel. For a three-way control on a settings page, build your own with useTheme:
"use client";
import { useTheme } from "@/components/ui/theme-toggle";
export function ThemeSelect() {
const { theme, setTheme } = useTheme();
return (
<label>
Theme
<select value={theme} onChange={(event) => setTheme(event.target.value as typeof theme)}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
);
}Other approaches and their trade-offs
| Approach | Flash-free | User choice | Trade-off |
|---|---|---|---|
CSS only (prefers-color-scheme) | Yes | No | Visitors cannot override their OS setting |
Inline script + localStorage | Yes | Yes | Needs suppressHydrationWarning and a CSP nonce if your policy requires one |
| Cookie read on the server | Yes, for explicit choices | Yes | Requires per-request rendering; "system" still needs client detection |
Effect after hydration (useEffect) | No | Yes | Flashes on every full page load |
For a statically generated documentation site or marketing page, the inline script is usually the right choice, because it keeps pages cacheable. For an app that already renders per request and has a user session, a cookie avoids the script for visitors who made an explicit choice.
How to confirm the flash is gone
A flash that lasts one frame on a fast machine can last much longer on a slow phone, so test under poor conditions rather than trusting your laptop:
- Choose dark mode on the site, so the stored value is
dark. - In your browser's developer tools, enable network and CPU throttling and disable the cache.
- Reload a page that is served as static HTML, and watch the first frames. A performance recording with screenshots makes a single light frame easy to spot.
- Repeat with the stored value set to
systemand the operating system set to dark, then again with storage cleared. - Open the console and confirm there is no hydration warning.
If a light frame still appears, check that the script is in <head>, that it is not marked async or defer, and that your Content Security Policy is not blocking it.
Whichever you choose, check both themes for contrast, not just the one you design in. Dark themes often fail on muted text and focus rings. The WCAG colour contrast guide covers what to check, and the theme generator produces light and dark brand roles that are contrast-checked as a set.
Frequently asked questions
Why does my Next.js app flash white before switching to dark mode?
The server sends HTML without the dark class because it cannot read localStorage. If the class is added in a useEffect, the browser has already painted the light theme. An inline script in the head that sets the class before the body renders removes the flash.
Why do I get a hydration warning on the html element?
The inline theme script changes the class attribute on html before React hydrates, so the client DOM no longer matches the server HTML. Add suppressHydrationWarning to the html element. It applies only to that element's own attributes, one level deep.
Should dark mode follow prefers-color-scheme by default?
Usually yes. Treat 'system' as the default choice, resolve it with matchMedia('(prefers-color-scheme: dark)'), and only store an explicit light or dark choice when the visitor makes one.
Can I avoid the inline script entirely?
If you only follow the system setting, CSS media queries alone avoid the flash. If you store the choice in a cookie, the server can render the right class, but the page then depends on per-request rendering. A stored client-side choice needs the inline script.
Keep reading
- Copy-paste components vs an npm component libraryWhen to copy component source into your React project and when to install a versioned package: ownership, upgrades, bundle size, and how to use both.
- Design tokens in Tailwind CSS v4 with @themeHow to build a semantic token layer in Tailwind CSS v4: @theme, @theme inline, CSS variables for light and dark, and naming roles instead of colours.
- Accessible modal dialogs in React: a checklistEverything an accessible React dialog has to do: role and labelling, focus entry, focus trap, Escape, focus return, scroll lock, and how to test each one.
Try Aback UI in your project
Accessible React components you install as source. MIT licensed.