Dark mode in Next.js without the flash

Fix the dark mode flash in Next.js with a blocking head script, suppressHydrationWarning, a Tailwind v4 dark variant, system preference and theme-color.

By MiniDev13 min read

To get dark mode in Next.js without a flash, decide the theme before the browser paints: put a tiny inline script in <head> that reads the saved choice (or the system preference) and adds a dark class to <html>, add suppressHydrationWarning to <html>, and point Tailwind's dark variant at that class. Everything else, the toggle, the system option and the browser theme-color, builds on those three pieces. This is the exact setup ui.minidev.pro runs.

Why the flash happens

A statically rendered or cached page is the same HTML for every visitor. The server cannot read localStorage and does not know the visitor's OS setting, so it sends the default (usually light) markup. If you apply the saved theme in a useEffect or a context provider, that code runs after hydration, which is after the first paint. The visitor sees light, then dark: a flash of the wrong theme, often loosely called FOUC.

There is a second trap. If you compute the class from React state during render, the server renders one value and the client another, and React reports a hydration mismatch. The fix below keeps the theme out of React's render entirely until after hydration.

Step 1: a blocking script in the head

An inline, non-module script in <head> runs synchronously while the HTML is parsed, before the body renders and before any paint. It is the only place early enough to set the class. Keep it tiny, wrap it in try (storage can throw in private modes or when blocked), and never let it depend on your bundle.

app/layout.tsxtsx
const themeScript = "(function(){try{var t=localStorage.getItem('theme');var d=t?t==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)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 className="bg-bg text-fg">{children}</body>
    </html>
  )
}

The logic: if a choice is stored, use it; otherwise follow prefers-color-scheme. MiniDev UI's own layout does the same and, in the same script, restores the data-material attribute. Anything that changes the first paint, such as theme, density or material, belongs in this one script.

If you ship a strict Content Security Policy, inline scripts are blocked unless you allow them with a nonce or a hash. Add the script's SHA-256 hash to script-src, or pass the request nonce to the tag.

Step 2: suppressHydrationWarning on html

The script changes the class attribute on <html> before React hydrates. React then compares the server's attributes with the DOM, sees the extra dark class, and warns. suppressHydrationWarning on <html> silences attribute mismatches on that one element. It is shallow: it does not hide mismatches in children, so it will not mask real bugs elsewhere. Do not spread it around your tree.

If <html> has other classes, such as font variables from next/font, keep them in the server className. The script only adds dark; it does not replace the attribute.

Step 3: the Tailwind v4 dark variant and tokens

Tailwind CSS v4's dark: variant uses the prefers-color-scheme media query by default, which ignores a manual choice. Redefine it to follow the class:

app/globals.csscss
@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

This is the line at the top of MiniDev's styles.css. It matches elements inside .dark. Tailwind's documentation also shows (&:where(.dark, .dark *)), which additionally matches the element that carries the class and adds no specificity; either works when the class sits on <html>.

The bigger decision is where dark styles live. Writing bg-white dark:bg-zinc-900 on every element works, but it doubles every color decision and one missed dark: is a bug. MiniDev UI uses semantic tokens instead: :root defines the light values, .dark redefines the same variables, and components only ever use the token utilities. Dark mode redefines tokens, never components.

css
:root {
  --bg: oklch(0.985 0.0015 264);
  --surface: oklch(1 0 0);
  --fg: oklch(0.185 0.012 268);
  --fg-muted: oklch(0.45 0.012 266);
  --border: oklch(0.917 0.004 264);
  color-scheme: light;
}

.dark {
  --bg: oklch(0.145 0.004 270);
  --surface: oklch(0.172 0.005 270);
  --fg: oklch(0.965 0.003 270);
  --fg-muted: oklch(0.73 0.01 270);
  --border: oklch(0.262 0.006 270);
  color-scheme: dark;
}

@theme inline {
  --color-bg: var(--bg);
  --color-surface: var(--surface);
  --color-fg: var(--fg);
  --color-fg-muted: var(--fg-muted);
  --color-border: var(--border);
}

@theme inline makes bg-surface compile to background-color: var(--surface), so the utility follows whichever value is active. color-scheme: dark tells the browser to draw native scrollbars, form controls and the default canvas in dark as well, which removes a second, subtler flash on inputs and scrollbars. The OKLCH colors guide explains why the tokens are written in OKLCH.

Light, dark and system

Offer three modes. Store "light" or "dark" when the visitor picks one, and remove the key for system, which makes the head script fall back to prefers-color-scheme on the next load.

lib/theme.tsts
export type Mode = "light" | "dark" | "system"

const media = () => window.matchMedia("(prefers-color-scheme: dark)")

export function readMode(): Mode {
  try {
    const t = localStorage.getItem("theme")
    return t === "light" || t === "dark" ? t : "system"
  } catch {
    return "system"
  }
}

export function applyMode(mode: Mode) {
  const root = document.documentElement
  const dark = mode === "dark" || (mode === "system" && media().matches)
  // Suppress transitions for two frames so every surface swaps at once.
  root.classList.add("[&_*]:!transition-none")
  root.classList.toggle("dark", dark)
  requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("[&_*]:!transition-none")))
  try {
    if (mode === "system") localStorage.removeItem("theme")
    else localStorage.setItem("theme", mode)
  } catch {}
}

The transition trick matters when components use transition-colors. Without it, every surface animates to its new color at its own duration and the swap looks smeared. Adding the arbitrary-variant class [&_*]:!transition-none to <html> disables transitions on every descendant for two frames. Tailwind generates it because the string appears in a source file it scans.

ThemePicker is a three-option radiogroup for Light, Dark and System. Drive it with the functions above:

components/theme-setting.tsxtsx
"use client"
import * as React from "react"
import { ThemePicker } from "@/components/ui/theme-picker"
import { applyMode, readMode, type Mode } from "@/lib/theme"

export function ThemeSetting() {
  const [mode, setMode] = React.useState<Mode>("system")
  React.useEffect(() => setMode(readMode()), [])
  return (
    <ThemePicker
      value={mode}
      onChange={(next) => {
        setMode(next)
        applyMode(next)
      }}
    />
  )
}

The state starts at "system" and is corrected after mount, so the server and client render the same markup. In system mode the page should also follow the OS when it changes (many people switch automatically at sunset). Mount one listener near the root:

components/theme-watcher.tsxtsx
"use client"
import * as React from "react"
import { applyMode, readMode } from "@/lib/theme"

export function ThemeWatcher() {
  React.useEffect(() => {
    const media = window.matchMedia("(prefers-color-scheme: dark)")
    const onSystem = () => { if (readMode() === "system") applyMode("system") }
    // Another tab changed the setting.
    const onStorage = (e: StorageEvent) => { if (e.key === "theme") applyMode(readMode()) }
    media.addEventListener("change", onSystem)
    window.addEventListener("storage", onStorage)
    return () => {
      media.removeEventListener("change", onSystem)
      window.removeEventListener("storage", onStorage)
    }
  }, [])
  return null
}

A toggle that is right on first paint

For a two-state button in a header, the MiniDev site uses a useTheme hook that treats the dark class on <html> as the source of truth. It reads the class after mount, subscribes with a MutationObserver so every toggle on the page stays in sync (including changes made by the watcher above), and writes through the same persist-and-suppress-transitions path:

components/theme-toggle.tsxtsx
export function useTheme() {
  const [dark, setDark] = React.useState(false)
  React.useEffect(() => {
    const el = document.documentElement
    setDark(el.classList.contains("dark"))
    const mo = new MutationObserver(() => setDark(el.classList.contains("dark")))
    mo.observe(el, { attributes: true, attributeFilter: ["class"] })
    return () => mo.disconnect()
  }, [])
  const toggle = () => applyMode(dark ? "light" : "dark")
  return { dark, toggle }
}

dark is false during server render and the first client render, then corrects itself. If the icon depends on that state, a dark-mode visitor sees the sun icon for a frame. Let CSS pick the icon instead, since the class is already correct at first paint:

tsx
<button type="button" onClick={toggle} aria-label="Toggle dark mode" className="grid size-8 place-items-center rounded-lg">
  <SunIcon className="size-4 dark:hidden" />
  <MoonIcon className="hidden size-4 dark:block" />
</button>

The same applies to logos and screenshots that differ per theme: render both and hide one with dark:hidden and hidden dark:block, and the right image is visible from the first frame. The rule of thumb: anything visible on first paint that depends on the theme should be decided by CSS (dark: or tokens), not by React state. State is fine for things that only appear after interaction, such as the checked option in a settings menu.

Match the browser theme-color

Mobile browsers tint the address bar from <meta name="theme-color">. In the App Router you declare it in the viewport export, and media queries let it follow the OS:

app/layout.tsxts
import type { Viewport } from "next"

export const viewport: Viewport = {
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "#fbfbfc" },
    { media: "(prefers-color-scheme: dark)", color: "#0e0f11" },
  ],
}

Use the same values as your --bg token. Media queries only know the OS setting, so a visitor who picked dark on a light OS gets a light address bar. Correct it at runtime by setting both tags to the active color whenever the mode is applied, and once on mount in ThemeWatcher:

ts
function syncThemeColor(dark: boolean) {
  document
    .querySelectorAll('meta[name="theme-color"]')
    .forEach((m) => m.setAttribute("content", dark ? "#0e0f11" : "#fbfbfc"))
}

Other approaches and when to use them

  • Cookie instead of localStorage. Read the theme with cookies() in the root layout and render the class on the server. No script and no hydration warning, but reading cookies makes every route dynamic, so you lose static rendering.
  • A library. next-themes packages the same blocking-script technique with a provider and a hook. It is a good choice if you want it maintained for you; the mechanics are identical.
  • Media query only. If you never offer a toggle, keep Tailwind's default dark variant and skip the script. The browser applies the right theme before paint on its own.

Checklist

  1. Inline script in <head> adds dark from storage or prefers-color-scheme, wrapped in try.
  2. suppressHydrationWarning on <html> only.
  3. @custom-variant dark (&:is(.dark *)); in your CSS.
  4. Colors are tokens redefined under .dark, with color-scheme set in both.
  5. Toggle persists the choice, removes it for system, and suppresses transitions during the swap.
  6. Theme-dependent icons and images are chosen by CSS, not state.
  7. theme-color follows the OS by default and the explicit choice at runtime.

Components and install

Every MiniDev UI component already ships light and dark through the token set in styles.css, so once the class is on <html> there is nothing else to wire. For a compact icon-based control, a SegmentedControl with icon on each option works as a theme switch too. The MiniDev studio builds complete products on this kit when you want the whole thing done for you.

Theme PickeruiLight, dark and system theme picker as a segmented radiogroup that toggles the dark class on the root element for Tailwind dark mode and reports it to onChange.npx shadcn@latest add ui.minidev.pro/r/theme-picker.jsonSegmented ControluiSegmented control with a raised thumb that springs between options, arrow, Home and End key support, icons, badges, three sizes and reduced motion support.npx shadcn@latest add ui.minidev.pro/r/segmented-control.json
bash
npx shadcn@latest add https://ui.minidev.pro/r/theme-picker.json

Components used in this guide

Frequently asked questions

Why does my Next.js site flash white before dark mode loads?

The server sends the default theme and your theme code runs after the first paint, usually in a useEffect. Set the dark class from an inline script in <head> so it applies before the page paints.

Do I need suppressHydrationWarning for dark mode in Next.js?

Yes, on the <html> element when a script changes its class before hydration. It only suppresses attribute mismatches on that one element, not in its children.

How do I make Tailwind v4 dark mode use a class instead of the media query?

Add @custom-variant dark (&:is(.dark *)); (or (&:where(.dark, .dark *))) to your CSS after importing Tailwind. The dark: variant then follows the dark class on <html>.

How do I respect the system theme and still let users choose?

Store an explicit light or dark choice, and remove it for system. The head script falls back to prefers-color-scheme when nothing is stored, and a matchMedia change listener keeps system mode live.

More guides