Back to Blog

Fix Next.js & Tailwind Dark Mode Flicker

Getting a white flash before dark mode loads in Next.js? Here's the exact 2026 fix for Tailwind v4's config, in one line of CSS.

M
Musab Bin Umair
10 min read

Disclosure: This post may contain affiliate links and display advertising. If you purchase through a link on this page, we may earn a commission at no extra cost to you. See our full disclaimer for details.

Fix Next.js & Tailwind Dark Mode Flicker
M
Musab Bin Umair
10 min read
Share this article
In This Article

I'll show you exactly how I use Web Design to find outdated content, get better insights, and update posts that rank.

When I upgraded one of my client's dashboards to Tailwind v4 earlier this year, dark mode looked completely fine in dev. Then I deployed it, opened the site fresh in an incognito tab with system dark mode on, and got a blinding flash of white before it snapped to dark half a second later. Classic FOUC — Flash of Unstyled Content, except in this case it's really a flash of the wrong theme. If you've landed here searching for "nextjs tailwind dark mode flicker," you're almost certainly dealing with one of two problems, or both at once: a hydration mismatch between server and client, and a Tailwind v4 config that quietly stopped working the way it did in v3.

I fixed both on that project, and I've since used the exact same setup on two more. Here's the whole thing, start to finish — why it happens, and the code that actually stops it.

Why Does Your Next.js Site Flash White Before Dark Mode Loads?

This part isn't really a Tailwind problem — it's a Next.js rendering problem, and it would happen even if you were using plain CSS.

Next.js renders your page on the server first. At that point, the server has no idea what theme the visitor prefers. It doesn't know if they toggled dark mode on your site last week, and it can't read localStorage because localStorage only exists in the browser. So the server ships down a page in whatever your default theme is — usually light.

Then the browser takes over. React hydrates the page, your client-side JavaScript runs, it checks localStorage (or window.matchMedia('(prefers-color-scheme: dark)')) to find out what theme the visitor actually wants, and then it applies the dark class to the <html> element. Everything between "server sends light mode" and "client applies dark mode" is the flicker. On a fast connection it might only last 100–200 milliseconds, but on mobile or a slower connection, it's very noticeable — and it's exactly the kind of thing that makes a site feel broken even when nothing is technically wrong.

The fix has two parts: you need to apply the theme class before the browser paints anything (not after React hydrates), and you need to tell React not to complain about the resulting server/client mismatch on that one attribute. That's what next-themes and suppressHydrationWarning are for, and I'll walk through both below.

Why Did My Dark Mode Break After Upgrading to Tailwind v4?

If your dark mode toggle was working fine in Tailwind v3 and just stopped after you upgraded, this is almost certainly your actual root cause — and it's the part most existing tutorials still get wrong, because they were written before v4 shipped.

What Actually Changed in Tailwind v4

In Tailwind v3, you controlled dark mode from tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: "class",
  // ...
};

Tailwind v4 moved to a CSS-first configuration model. There's no tailwind.config.js loaded by default anymore, which means the darkMode: "class" key you used to set simply isn't read by anything. Without it, Tailwind v4 falls back to its default behavior: dark: utilities compile straight to a prefers-color-scheme: dark media query, ignoring any dark class you're toggling on the <html> element entirely.

So if you added a manual dark/light toggle to your site, and it stopped responding after upgrading to v4, this is why — your dark:bg-gray-900 classes are still there, they're just now only listening to the operating system, not your toggle button.

The One-Line Fix for globals.css

The fix is genuinely one line, added directly to your globals.css file, right after your Tailwind import:

@import "tailwindcss";

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

That line tells Tailwind v4 to go back to matching a .dark class anywhere up the tree — on the element itself or on an ancestor — instead of relying purely on the media query. Once that's in your CSS, every dark: utility you already wrote in your components starts responding to the class again, no other code changes needed.

A couple of things worth double-checking here, because I've seen both trip people up:

  • Make sure this line comes after @import "tailwindcss";, not before it.
  • If you ran Tailwind's automated @tailwindcss/upgrade tool, open your CSS file and actually look at what it generated. In a few projects I've reviewed, the upgrade tool rewrote the variant into something that doesn't behave like a plain class toggle anymore — it's worth confirming the line matches the one above exactly. You can cross-check it against the official Tailwind CSS dark mode docs if anything looks off.

The Complete Fix: next-themes + suppressHydrationWarning

The Tailwind fix above solves whether dark mode works at all. It doesn't solve the flicker on its own — for that, you need to control exactly when and how the theme class gets applied, which is what next-themes handles for you instead of hand-rolling your own localStorage + useEffect logic (which is where most of the flicker bugs I've debugged for people actually come from).

Step 1: Install next-themes

npm install next-themes

It's a small, actively maintained library built specifically for this problem — worth reading through the next-themes documentation on GitHub if you want to see every option it exposes.

Step 2: Build the ThemeProvider Wrapper

Create a small client component to wrap the library's provider. This needs "use client" at the top since it relies on browser APIs:

// components/theme-provider.tsx
"use client";

import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes";

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

Step 3: Wrap Your Root Layout (and Add suppressHydrationWarning)

This is the step people skip, and it's the one that actually kills the flicker. In your app/layout.tsx:

// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Two details here matter more than they look:

  • attribute="class" tells next-themes to toggle a dark class on <html> — which is exactly what the @custom-variant line you added earlier is watching for.
  • suppressHydrationWarning on the <html> tag is what stops React from throwing a console error about the theme class not matching between server and client. next-themes injects a tiny inline script that runs before React hydrates and sets the correct class immediately — so the mismatch is expected and harmless, but React still needs to be told not to warn about it.

Step 4: Build a Theme Toggle That Actually Works

// components/theme-toggle.tsx
"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  // Avoid rendering theme-dependent UI until mounted on the client
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;

  return (
    <button
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      className="rounded-md p-2 dark:bg-gray-800 dark:text-white"
    >
      {theme === "dark" ? "☀️ Light" : "🌙 Dark"}
    </button>
  );
}

The mounted check is a small but genuinely important detail — it stops this specific component from rendering theme-dependent text or icons on the server before the client knows what theme is active, which would otherwise reintroduce a tiny flicker on the toggle button itself.

Why suppressHydrationWarning Doesn't "Hide" the Problem

I want to flag something because I see it misunderstood constantly: suppressHydrationWarning is not a blanket "ignore all hydration errors" switch, and you shouldn't use it that way. It only suppresses the mismatch warning for the exact element it's placed on — in this case, the class and style attributes on <html>. Any other genuine hydration mismatch elsewhere in your component tree (mismatched text content, conditionally rendered elements, date formatting that differs between server and client) will still throw its normal warning in the console.

That's actually the whole point. The theme class is supposed to differ between what the server rendered and what the client applies a moment later, because the server genuinely can't know the visitor's preference. Suppressing the warning on just that one attribute is the correct, narrow use of the prop — not a workaround for sloppy code elsewhere.

Quick Checklist: How to Confirm the Flicker Is Actually Gone

Before you call this done, test it properly rather than just eyeballing it once:

  1. Set your OS to dark mode, then open your site in a fresh incognito window — this rules out any cached theme from localStorage.
  2. Throttle your network to "Slow 3G" in Chrome DevTools and reload. If there's still a flash under throttling, something upstream (usually a slow-loading font or a blocking script above the theme script) is delaying the paint.
  3. Check the browser console for hydration warnings unrelated to <html> — if you see any, they're a separate bug next-themes won't fix for you.
  4. Toggle the theme manually, refresh the page, and confirm it persisted — this confirms next-themes is actually writing to localStorage correctly.

If it passes all four, you're done.

FAQs

Why does my Next.js website flash white before loading dark mode? Because the server renders your page before it knows the visitor's theme preference (it can't read localStorage or the OS setting during server rendering), so it ships a default-theme page first. The correct theme only gets applied once client-side JavaScript runs a moment later — and that gap between server paint and client correction is the flicker.

How do I fix the dark mode flicker in Tailwind CSS? Install next-themes, wrap your root layout with its ThemeProvider using attribute="class", and add suppressHydrationWarning to your <html> tag. next-themes injects a small script that sets the correct theme class before the page paints, which is what actually removes the flash — suppressHydrationWarning just stops React from logging a (harmless, expected) warning about it.

Why did my dark mode stop working after upgrading to Tailwind v4? Tailwind v4 dropped support for the darkMode: "class" key in tailwind.config.js as part of its move to CSS-first configuration, so it now defaults to matching prefers-color-scheme only. Add @custom-variant dark (&:where(.dark, .dark *)); to your globals.css, right after @import "tailwindcss";, to restore class-based toggling.

Do I still need suppressHydrationWarning if I'm not using next-themes? If you're rolling your own theme logic with a class toggled by client-side JavaScript, yes — the same server/client mismatch exists either way. You'd also need to manually add an inline script in your <head> that runs before hydration, which is essentially what next-themes already does for you, more reliably.


If you're mid-upgrade on a Next.js project generally and this isn't the only breaking change you've run into, I covered the broader migration path — including caching gotchas that trip people up just as often as this one — in my Next.js 15 features and upgrade guide.

Tags:Next.jsTailwind CSSWeb DesignDark Mode
M

About Musab Bin Umair

Expert tech writer and AI enthusiast passionate about exploring the intersection of modern productivity tools and digital growth strategies.

View all posts by Musab Bin Umair

You Might Also Like

View all
13 min read

Fix Next.js 15 Hydration Flicker

Hero section flickering on load in Next.js 15? Here's why Framer Motion triggers hydration mismatches, and the fix that works for good.

M
Musab Bin Umair

Stay Ahead in AI

Get weekly AI tool updates and tech tips delivered to your inbox.