How to set up dark mode in a Tailwind + Next.js app
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Class-based dark mode with system-preference detection and a toggle. ~30 minutes.
Goal
A working dark mode that:
- Respects the user’s system preference on first load
- Lets the user override with a toggle
- Persists the choice across page reloads
- Doesn’t flash light mode for a moment when dark is selected (the dreaded FOUC — flash of unstyled content)
Prerequisites
- Next.js app with Tailwind CSS
- Tailwind CSS v3 or later (this guide uses v3+ conventions)
Steps
1. Configure Tailwind to use class-based dark mode
In tailwind.config.ts:
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class", // ← critical
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
theme: {
extend: {}
},
plugins: [],
};
export default config;"class" mode means: dark styles activate when the <html> element (or any ancestor) has the dark class. This is the most flexible option — your code controls when dark is on.
The alternative "media" only follows system preference and gives no user toggle. Stick with "class".
2. Install next-themes (the modern default)
npm install next-themesnext-themes handles all the tricky bits: detecting system preference, persisting the choice in localStorage, syncing across tabs, avoiding the FOUC.
3. Wrap your app in the theme provider
Create app/providers.tsx:
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}Then in app/layout.tsx:
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}The suppressHydrationWarning on <html> is necessary because the theme provider modifies the class on the client immediately, which would otherwise cause a hydration mismatch warning. This is documented and expected.
4. Build the toggle
Create components/ThemeToggle.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 hydration mismatch by rendering nothing until mounted
useEffect(() => setMounted(true), []);
if (!mounted) return <button aria-label="Toggle theme" />;
const isDark = theme === "dark";
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label="Toggle theme"
>
{isDark ? "☀️" : "🌙"}
</button>
);
}The mounted check prevents a hydration mismatch: the server renders without knowing the user’s theme; the client knows it. By rendering a placeholder until mounted, the markup matches across server/client.
5. Add the toggle to your layout (e.g. in the navbar)
import { ThemeToggle } from "@/components/ThemeToggle";
export function Navbar() {
return (
<header>
<h1>My Site</h1>
<nav>
<a href="/">Home</a>
<ThemeToggle />
</nav>
</header>
);
}6. Use dark: variants in your Tailwind classes
<div className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
Hello
</div>
<button className="rounded bg-blue-500 px-4 py-2 text-white dark:bg-blue-700">
Click me
</button>Wherever you have a color, write the dark variant too. Tailwind’s dark: prefix applies the style only when dark mode is active.
For larger projects, define semantic color tokens instead of repeating dark variants everywhere:
// tailwind.config.ts
theme: {
extend: {
colors: {
surface: {
DEFAULT: "rgb(255 255 255)",
dark: "rgb(15 23 42)", // slate-900
},
ink: {
DEFAULT: "rgb(15 23 42)",
dark: "rgb(241 245 249)", // slate-100
}
}
}
}Then use bg-surface dark:bg-surface-dark text-ink dark:text-ink-dark everywhere. Change the palette once, get a coherent redesign for free.
(More modern alternative in Tailwind v4: CSS custom properties + @custom-variant dark. Sticking with v3 conventions here for compatibility.)
7. Optional: prevent the FOUC manually
next-themes already handles this via a <script> it injects. If you see a brief flash anyway:
- Make sure
<html lang="en" suppressHydrationWarning>is set - Make sure
defaultTheme="system"is on the provider - For ultra-strict cases, use
next-themes’forcedThemeprop to lock a page
8. Test
- Reload with system in dark mode → app starts in dark
- Click the toggle → switches to light
- Reload → still light (persistence)
- Switch system to light → app stays light (your toggle wins until you reset)
- Click “system” if your toggle has a third option → respects system again
Verification
- ✅ Toggle works
- ✅ Choice persists across reloads
- ✅ Initial render matches user’s saved preference (no FOUC)
- ✅ Colors look right in both modes (sufficient contrast — see accessibility)
- ✅ No hydration warnings in the console
Common failures
Toggle flickers light → dark on reload
The mounted-check pattern in step 4 prevents this. Make sure your toggle doesn’t render the actual icon until mounted is true.
”Hydration failed” warnings
Either:
- Missing
suppressHydrationWarningon<html> - Component using theme without the
mountedcheck - A child component reading
themeand rendering different things server vs client
Dark mode works but colors look terrible
Color choices, not config. Pick a palette with sufficient contrast. Test with the WebAIM contrast checker. Aim for WCAG AA (4.5:1 for normal text).
Toggle changes theme but page styles don’t change
You’re using Tailwind utilities without dark: variants. Tailwind isn’t psychic — you have to write dark:bg-slate-900 etc.
Works locally, broken in production
Vercel build cache. Force a clean deploy: in Vercel Deployments → … → Redeploy with “Use existing Build Cache” unchecked.
System preference detection is wrong
The browser exposes prefers-color-scheme: dark via CSS media query and JS matchMedia. If it’s wrong, the browser/OS is misreporting. Test in a different browser.
Bonus: a “system” option
next-themes supports defaultTheme="system" — meaning “follow OS preference unless user has explicitly chosen.” With a third toggle option for “system” you can let users pick: Light, Dark, or Auto.
const { theme, setTheme } = useTheme();
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">Auto</option>
</select>What you’ve just built
A production-quality dark mode:
- Respects system preference initially
- User-overridable with a persistent choice
- No FOUC
- Accessible (keyboard-toggleable, screen-reader-labeled)
- Works on every page automatically (via the provider in
layout.tsx)
See also
- Dark mode (technical) 🟩 — the underlying CSS mechanics
- Dark mode design 🟩 — the design decisions (not just inversion)
- Color theory for devs 🟩 — how to pick the values
- Tailwind 🟩
- ui 🟩 — semantic tokens
- Next.js 🟩 🟦
- Accessibility (technical) 🟩
- Accessibility (design view) 🟩 — contrast applies to both modes
- Tailwind gotchas 🟥