Dark Mode
Dark mode, color schemes, and dynamic theming with Tailwind CSS v4.
Recipe
Quick-reference recipe card — copy-paste ready.
// Dark mode utilities — just prefix with dark:
<div className="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<p className="text-gray-600 dark:text-gray-400">Adapts to system preference</p>
<button className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400">
Click me
</button>
</div>/* globals.css — Tailwind v4 uses prefers-color-scheme by default */
@import "tailwindcss";
/* For class-based dark mode (toggled via JS): */
@variant dark (&:where(.dark, .dark *));
/* Semantic tokens that auto-switch */
@theme {
--color-surface: #ffffff;
--color-on-surface: #111827;
}
@layer base {
.dark {
--color-surface: #0f172a;
--color-on-surface: #f1f5f9;
}
}When to reach for this: Every user-facing application should support dark mode — it reduces eye strain and respects user preferences.
Working Example
"use client";
import { useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === "system") {
root.classList.remove("dark");
const mq = window.matchMedia("(prefers-color-scheme: dark)");
if (mq.matches) root.classList.add("dark");
const handler = (e: MediaQueryListEvent) => {
root.classList.toggle("dark", e.matches);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}
root.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}, [theme]);
return (
<div className="min-h-screen bg-surface text-on-surface transition-colors duration-300">
<header className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-800">
<h1 className="text-lg font-bold">My App</h1>
<ThemeToggle theme={theme} setTheme={setTheme} />
</header>
<main className="mx-auto max-w-4xl p-6">{children}</main>
</div>
);
}
function ThemeToggle({
theme,
setTheme,
}: {
theme: Theme;
setTheme: (t: Theme) => void;
}) {
const options: { value: Theme; label: string }[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
return (
<div className="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
theme === opt.value
? "bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
}`}
>
{opt.label}
</button>
))}
</div>
);
}/* globals.css for the above example */
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #ffffff;
--color-on-surface: #111827;
--color-surface-muted: #f9fafb;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
}
@layer base {
.dark {
--color-surface: #0f172a;
--color-on-surface: #f1f5f9;
--color-surface-muted: #1e293b;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
}
}What this demonstrates:
- Class-based dark mode toggle with system fallback
- Semantic color tokens via CSS custom properties
- Theme persistence in
localStorage - System preference detection with
matchMedia - Smooth color transitions with
transition-colors
Deep Dive
How It Works
- By default, Tailwind v4
dark:uses@media (prefers-color-scheme: dark)— no config needed - For class-based toggling, override with
@variant dark (&:where(.dark, .dark *))in your CSS - The
:where()selector keeps specificity at zero, sodark:bg-gray-900does not outweighbg-whitebased on specificity - Semantic tokens (CSS custom properties) let you define colors once and swap them in dark mode
next-themesis the standard library for Next.js dark mode — it handles SSR, flash prevention, and system sync
Variations
Using next-themes:
// app/layout.tsx
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}
// Component usage
"use client";
import { useTheme } from "next-themes";
function Toggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Toggle
</button>
);
}Multiple themes (beyond light/dark):
@layer base {
[data-theme="ocean"] {
--color-primary: #0891b2;
--color-surface: #f0fdfa;
}
[data-theme="sunset"] {
--color-primary: #ea580c;
--color-surface: #fff7ed;
}
}Dark-mode-aware images:
<picture>
<source srcSet="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
<img src="/logo-light.svg" alt="Logo" />
</picture>
{/* Or with Tailwind: */}
<img src="/logo-light.svg" className="dark:hidden" alt="Logo" />
<img src="/logo-dark.svg" className="hidden dark:block" alt="Logo" />TypeScript Notes
// Theme type
type Theme = "light" | "dark" | "system";
// next-themes types
import type { ThemeProviderProps } from "next-themes";
// Type-safe theme colors
const themeColors = {
light: { bg: "bg-white", text: "text-gray-900" },
dark: { bg: "bg-gray-950", text: "text-gray-100" },
} as const satisfies Record<string, { bg: string; text: string }>;Gotchas
-
Flash of wrong theme (FOIT) — Without SSR handling, the page briefly shows the wrong theme. Fix: Use
next-themeswithsuppressHydrationWarningon<html>, or add a blocking<script>in<head>that sets the class. -
prefers-color-schemevs class toggle — The default v4 behavior uses the media query. If you add.darkclass toggling, you must override the variant. Fix: Add@variant dark (&:where(.dark, .dark *)). -
Opacity modifiers in dark mode —
bg-blue-500/50works, butdark:bg-blue-500/50might not compose with semantic tokens. Fix: Define separate opacity values in your theme tokens. -
Color contrast — Dark mode is not just "invert everything." Light text on dark backgrounds needs different contrast ratios. Fix: Test with WCAG contrast checkers.
-
Images and shadows — Bright images and dark shadows look wrong in dark mode. Fix: Reduce shadow intensity (
dark:shadow-none), add overlays on images, or usebrightnessfilter.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
next-themes | Next.js app with SSR (prevents flash) | Non-Next.js projects |
CSS color-scheme: dark | You want browser UI (scrollbars, inputs) to adapt | You need custom component theming |
prefers-color-scheme only | You want automatic system-based dark mode | Users need manual toggle control |
Tailwind darkMode: "selector" (v3) | You are on Tailwind v3 | You are on v4 |
FAQs
What is the default dark mode strategy in Tailwind v4?
By default, dark: uses @media (prefers-color-scheme: dark) — it follows the user's operating system preference with no configuration needed.
How do you switch from media-query dark mode to class-based toggling?
Add this to your CSS:
@variant dark (&:where(.dark, .dark *));Then toggle the .dark class on the <html> element via JavaScript.
What are semantic color tokens and why use them?
- CSS custom properties like
--color-surfaceand--color-on-surface - Defined once in
:root, overridden in.dark - Components use
bg-surfaceinstead ofbg-white dark:bg-gray-950 - Reduces duplication and makes theme changes easier
What is the recommended library for dark mode in Next.js?
next-themes. It handles SSR, prevents flash of wrong theme, supports system preference, and works with the class attribute strategy.
Why is suppressHydrationWarning needed on the <html> tag?
next-themes adds the class attribute on the client before hydration. Without suppressHydrationWarning, React warns about the server/client mismatch.
How do you support more than two themes (e.g., ocean, sunset)?
@layer base {
[data-theme="ocean"] {
--color-primary: #0891b2;
}
[data-theme="sunset"] {
--color-primary: #ea580c;
}
}Use a data-theme attribute instead of the .dark class.
How do you show different images for light and dark mode?
<img src="/logo-light.svg" className="dark:hidden" alt="Logo" />
<img src="/logo-dark.svg" className="hidden dark:block" alt="Logo" />Gotcha: The page flashes the wrong theme on load. What causes this?
The server renders without knowing the user's theme. The client then applies the correct theme, causing a visible flash. Fix by using next-themes or adding a blocking <script> in <head> that sets the class before paint.
Gotcha: Dark mode colors look washed out after swapping shade 500 to 400. Why?
oklch lightness is not symmetric. Simply changing the shade number does not guarantee good contrast in dark mode. Test colors visually rather than inverting numbers.
How do you type the Theme union in TypeScript?
type Theme = "light" | "dark" | "system";For next-themes, import ThemeProviderProps from next-themes for full type support.
How would you create a type-safe theme color map in TypeScript?
const themeColors = {
light: { bg: "bg-white", text: "text-gray-900" },
dark: { bg: "bg-gray-950", text: "text-gray-100" },
} as const satisfies Record<string, { bg: string; text: string }>;Related
- Setup — Tailwind v4 theme configuration
- Custom Utilities — creating theme-aware utilities
- shadcn Setup — shadcn theming with dark mode
- Utilities — core utility classes