Skip to content
Aback UI

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.

By the Aback UI teamPublished 7 min read

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:

tsx
"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:

index.htmlhtml
<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:

app/layout.tsxtsx
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

This script blocks rendering, so it should do one thing: set a class. Do not fetch, import modules, or read large data in it.

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:

styles/theme.csscss
: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:

css
@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 valueMeaningResolved theme
(nothing)No choice made yetFrom prefers-color-scheme
systemExplicitly follow the OSFrom prefers-color-scheme
lightExplicit choiceLight
darkExplicit choiceDark

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:

ts
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:

tsx
<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 clickable div.
  • 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:

components/theme-select.tsxtsx
"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

ApproachFlash-freeUser choiceTrade-off
CSS only (prefers-color-scheme)YesNoVisitors cannot override their OS setting
Inline script + localStorageYesYesNeeds suppressHydrationWarning and a CSP nonce if your policy requires one
Cookie read on the serverYes, for explicit choicesYesRequires per-request rendering; "system" still needs client detection
Effect after hydration (useEffect)NoYesFlashes 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:

  1. Choose dark mode on the site, so the stored value is dark.
  2. In your browser's developer tools, enable network and CPU throttling and disable the cache.
  3. 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.
  4. Repeat with the stored value set to system and the operating system set to dark, then again with storage cleared.
  5. 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.

Share this post

Try Aback UI in your project

Accessible React components you install as source. MIT licensed.

Get started