Flicker-free Dark Mode
Use next-themes with the attribute="class" strategy so Tailwind's dark: utilities work. Wrap your root layout in a ThemeProvider to expose the toggle across the app.
Dark Mode with Tailwind & next-themes
Dark mode is no longer a nice-to-have — users expect it. Get it wrong and you get the dreaded white flash on page load, or worse, a dark mode that ignores the user's system preference. Get it right and it feels seamless — the app remembers the user's choice, respects their OS setting by default, and switches instantly without any flicker.
This lesson builds a complete, production-ready dark mode system from scratch —
system preference detection, user-controlled toggle, localStorage persistence,
and zero flash on page load. We'll use next-themes with Tailwind's
dark: variant.
1. The Flash Problem — Why Dark Mode Is Hard
Here's the core challenge. Next.js renders HTML on the server. The server doesn't know the user's preferred theme — that's stored in localStorage in the browser. So the server renders with the default theme (light), sends the HTML, and then JavaScript runs in the browser, reads localStorage, and switches to dark mode. That switch causes a white flash — sometimes called FOUC (Flash of Unstyled Content).
The solution is to inject a tiny blocking script into the HTML <head>
that reads localStorage before the page renders, and adds the correct
theme class to the <html> element before any content paints.
next-themes handles this for you automatically.
2. Setting Up next-themes
Install the package:
npm install next-themes
Configure Tailwind to use the class-based dark mode strategy — this means dark
mode activates when a .dark class is on the <html>
element, rather than relying purely on the OS media query:
/* app/globals.css */
@import "tailwindcss";
/* Tell Tailwind to use class-based dark mode */
@variant dark (&.dark);
Now create a ThemeProvider wrapper. It must be a Client Component
because it uses React context internally:
// components/ThemeProvider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<NextThemesProvider
attribute="class" // Add "dark" class to <html> element
defaultTheme="system" // Default to system preference
enableSystem // Detect OS dark/light preference
disableTransitionOnChange // Prevent flash during theme switch
>
{children}
</NextThemesProvider>
);
}
Wrap your root layout with the provider. Keep the root layout itself a Server Component — only the provider needs to be a Client Component:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: "My App",
description: "My Next.js application",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
{/*
suppressHydrationWarning on <html> is required —
next-themes modifies the class attribute before hydration,
which would normally trigger a React hydration mismatch warning.
*/}
<body className={inter.variable}>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
Important: suppressHydrationWarning on the
<html> element is not optional — without it you'll get
React hydration warnings in development because next-themes modifies the
class attribute before React hydrates.
3. Adding dark: Classes to Components
With the setup complete, prefix any Tailwind utility with dark:
to apply it when dark mode is active:
// Basic dark mode styles
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<h1 className="text-2xl font-bold">Hello</h1>
</div>
// Cards
<div className="
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-lg p-6 shadow-sm dark:shadow-gray-900/20
">
<h2 className="text-gray-900 dark:text-gray-100 font-semibold">Card Title</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">Card description.</p>
</div>
// Navigation
<nav className="bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800">
<a className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Home
</a>
</nav>
// Inputs
<input className="
bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-600
text-gray-900 dark:text-gray-100
placeholder:text-gray-400 dark:placeholder:text-gray-500
rounded-lg px-3 py-2
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
" />
4. Building a Theme Toggle Component
The useTheme hook from next-themes gives you the current theme
and a function to change it. Build a toggle button users can click:
// components/ThemeToggle.tsx
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Only render after mounting to avoid hydration mismatch
// (server doesn't know the theme, client does)
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
// Render a placeholder with the same dimensions to prevent layout shift
return <div className="w-9 h-9 rounded-lg" />;
}
const isDark = resolvedTheme === "dark";
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="
w-9 h-9 rounded-lg flex items-center justify-center
bg-gray-100 dark:bg-gray-800
hover:bg-gray-200 dark:hover:bg-gray-700
text-gray-600 dark:text-gray-400
transition-colors duration-200
"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
// Sun icon — shown in dark mode
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
) : (
// Moon icon — shown in light mode
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
)}
</button>
);
}
The mounted check is essential. On the server and during the initial
client render, the theme is unknown. If you render the toggle without this check,
you'll get a hydration mismatch because the server renders one icon and the client
might render another. The placeholder div keeps the layout stable.
Three-way Toggle — Light / Dark / System
Power users want a three-way toggle that includes the "follow system" option:
// components/ThemeSelector.tsx
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
const themes = [
{ value: "light", label: "Light", icon: "☀️" },
{ value: "dark", label: "Dark", icon: "🌙" },
{ value: "system", label: "System", icon: "💻" },
] as const;
export default function ThemeSelector() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<div className="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
{themes.map((t) => (
<button
key={t.value}
onClick={() => setTheme(t.value)}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-colors
${theme === t.value
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}
`}
aria-pressed={theme === t.value}
>
{t.icon} {t.label}
</button>
))}
</div>
);
}
5. Using CSS Variables for Theming
For complex apps, managing dark: variants on every element gets
repetitive. A cleaner approach is defining semantic CSS variables that change
value based on the theme — then you only reference the variable, not the dark/light
colour directly:
/* app/globals.css */
@import "tailwindcss";
@variant dark (&.dark);
/* Light mode variables (default) */
:root {
--bg-primary: 255 255 255; /* white */
--bg-secondary: 249 250 251; /* gray-50 */
--bg-elevated: 255 255 255; /* white */
--text-primary: 17 24 39; /* gray-900 */
--text-secondary: 107 114 128; /* gray-500 */
--text-muted: 156 163 175; /* gray-400 */
--border-color: 229 231 235; /* gray-200 */
--border-subtle: 243 244 246; /* gray-100 */
--ring-color: 59 130 246; /* blue-500 */
}
/* Dark mode variables */
.dark {
--bg-primary: 3 7 18; /* gray-950 */
--bg-secondary: 17 24 39; /* gray-900 */
--bg-elevated: 31 41 55; /* gray-800 */
--text-primary: 249 250 251; /* gray-50 */
--text-secondary: 156 163 175; /* gray-400 */
--text-muted: 107 114 128; /* gray-500 */
--border-color: 55 65 81; /* gray-700 */
--border-subtle: 31 41 55; /* gray-800 */
--ring-color: 96 165 250; /* blue-400 */
}
Note the values are stored as RGB channels without the rgb() wrapper —
this lets Tailwind's opacity modifier (bg-primary/50) work correctly
with them:
/* app/globals.css — continued */
@theme {
--color-bg-primary: rgb(var(--bg-primary));
--color-bg-secondary: rgb(var(--bg-secondary));
--color-bg-elevated: rgb(var(--bg-elevated));
--color-text-primary: rgb(var(--text-primary));
--color-text-secondary: rgb(var(--text-secondary));
--color-text-muted: rgb(var(--text-muted));
--color-border: rgb(var(--border-color));
--color-border-subtle: rgb(var(--border-subtle));
}
// Now components are much cleaner — no dark: prefix needed for base styles
<div className="bg-bg-primary text-text-primary border border-border rounded-lg p-6">
<h2 className="text-text-primary font-semibold">Card Title</h2>
<p className="text-text-secondary mt-2">Card description.</p>
</div>
// Only use dark: for things that genuinely differ beyond the semantic tokens
<button className="bg-blue-600 dark:bg-blue-500 text-white">
Primary Action
</button>
6. Dark Mode for Images
Some images need different versions for light and dark mode — logos, illustrations,
and diagrams are common examples. Use the dark: variant with
hidden and block to swap them:
// Light and dark logo variants
<div>
<img
src="/logo-light.svg"
alt="My App"
className="dark:hidden" // Visible in light mode, hidden in dark
/>
<img
src="/logo-dark.svg"
alt="My App"
className="hidden dark:block" // Hidden in light mode, visible in dark
/>
</div>
For illustrations or screenshots that just need to look softer in dark mode without swapping entirely, reduce opacity instead:
<img
src="/screenshot.png"
alt="App screenshot"
className="opacity-100 dark:opacity-80 dark:brightness-90"
/>
7. Smooth Theme Transitions
By default, switching themes is instant — elements jump from light to dark colours. You can add a smooth transition, but be careful: transitioning every property on every element can cause visual glitches. Target only the properties that change:
/* app/globals.css */
/* Add transition only to properties that change between themes */
body,
body * {
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.15s ease;
}
The disableTransitionOnChange prop on NextThemesProvider
temporarily disables transitions during the initial theme detection — preventing
a flash of transition animation on page load. Keep it set to true
even if you add custom transitions.
8. Reading the Theme in Server Components
useTheme is a client-side hook — you can't use it in Server Components.
For server-rendered theme-aware content (rare, but sometimes needed), you can read
the theme from cookies. next-themes stores it in a cookie when server-side rendering
is configured:
// components/ThemeProvider.tsx — enable cookie storage
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
storageKey="theme" // localStorage key
>
// app/some-server-component.tsx
import { cookies } from "next/headers";
export default async function ServerThemeAwareComponent() {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "system";
return (
<div>
<p>Current theme preference: {theme}</p>
</div>
);
}
This is rarely needed — the CSS-based dark mode system handles visual theming automatically. Only reach for server-side theme reading if you need to conditionally render different content (not just different styles) based on the theme.
9. A Complete Dark Mode Component Checklist
When building or reviewing a component for dark mode support, check these:
| Element | Light | Dark |
|---|---|---|
| Page background | bg-white |
dark:bg-gray-950 |
| Card/panel background | bg-white |
dark:bg-gray-800 |
| Body text | text-gray-900 |
dark:text-gray-100 |
| Secondary text | text-gray-500 |
dark:text-gray-400 |
| Borders | border-gray-200 |
dark:border-gray-700 |
| Input background | bg-white |
dark:bg-gray-900 |
| Input border | border-gray-300 |
dark:border-gray-600 |
| Placeholder text | placeholder:text-gray-400 |
dark:placeholder:text-gray-500 |
| Hover background | hover:bg-gray-100 |
dark:hover:bg-gray-800 |
| Code blocks | bg-gray-100 text-gray-800 |
dark:bg-gray-800 dark:text-gray-200 |
| Shadows | shadow-sm |
dark:shadow-gray-900/50 |
10. Common Gotchas
-
Forgetting
suppressHydrationWarningon<html>. Without it, React throws a hydration warning in development because next-themes adds a class to<html>before React hydrates. This is a false positive — the app works fine — but the warning is noisy and can mask real issues. -
Rendering theme-dependent UI without the
mountedcheck. Any component that renders differently based on the theme (like the toggle button) must checkmountedbefore rendering. The server doesn't know the theme, so the first render must match the server output — render a neutral placeholder instead. -
Using
themeinstead ofresolvedThemefor icons.themecan be"system"— a string that doesn't tell you whether the system is in dark or light mode. UseresolvedThemewhich is always either"light"or"dark". -
Forgetting
@variant dark (&.dark)in globals.css. Without this, Tailwind'sdark:variant uses the default media query strategy instead of the class strategy — your toggle won't work. -
Missing dark styles on third-party components.
If you use a library that applies its own colours (charts, maps, rich text editors),
check if it has a dark mode prop or theme option. The CSS
:globalescape hatch in CSS Modules or Tailwind'sdark:[&_.third-party-class]syntax can override third-party styles when needed.
Key Takeaways
- Use
next-themeswithattribute="class"and@variant dark (&.dark)in Tailwind for class-based dark mode. - Add
suppressHydrationWarningto the<html>tag — it's required, not optional. - Always check
mountedbefore rendering theme-dependent UI to avoid hydration mismatches. - Use
resolvedThemenotthemewhen you need to know the actual current theme —themecan be"system". - CSS variables that change value with the
.darkclass keep components clean — fewerdark:prefixes needed. - Store colours as raw RGB channel values (without
rgb()) to enable Tailwind opacity modifiers. disableTransitionOnChangeon the provider prevents a flash of transition animation on initial page load.
That wraps up Module 3 — Styling & UI! You now have a complete styling toolkit: CSS Modules for scoped component styles, Tailwind for utility-first rapid development, next/font for zero-layout-shift typography, and a flicker-free dark mode system. In Module 4 we move to SEO — the Metadata API, Open Graph images, sitemaps, structured data, and Core Web Vitals.