Dark Mode
Dark mode, color schemes, and dynamic theming with Tailwind CSS v4.
Search across all documentation pages
Dark mode, color schemes, and dynamic theming with Tailwind CSS v4.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
localStoragematchMediatransition-colorsdark: uses @media (prefers-color-scheme: dark) - no config needed@variant dark (&:where(.dark, .dark *)) in your CSS:where() selector keeps specificity at zero, so dark:bg-gray-900 does not outweigh bg-white based on specificitynext-themes is the standard library for Next.js dark mode - it handles SSR, flash prevention, and system syncUsing 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" />// 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 }>;Flash of wrong theme (FOIT) - Without SSR handling, the page briefly shows the wrong theme. Fix: Use next-themes with suppressHydrationWarning on <html>, or add a blocking <script> in <head> that sets the class.
prefers-color-scheme vs class toggle - The default v4 behavior uses the media query. If you add .dark class toggling, you must override the variant. Fix: Add @variant dark (&:where(.dark, .dark *)).
Opacity modifiers in dark mode - bg-blue-500/50 works, but dark:bg-blue-500/50 might 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 use brightness filter.
| 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 |
By default, dark: uses @media (prefers-color-scheme: dark) - it follows the user's operating system preference with no configuration needed.
Add this to your CSS:
@variant dark (&:where(.dark, .dark *));Then toggle the .dark class on the <html> element via JavaScript.
--color-surface and --color-on-surface:root, overridden in .darkbg-surface instead of bg-white dark:bg-gray-950next-themes. It handles SSR, prevents flash of wrong theme, supports system preference, and works with the class attribute strategy.
next-themes adds the class attribute on the client before hydration. Without suppressHydrationWarning, React warns about the server/client mismatch.
@layer base {
[data-theme="ocean"] {
--color-primary: #0891b2;
}
[data-theme="sunset"] {
--color-primary: #ea580c;
}
}Use a data-theme attribute instead of the .dark class.
<img src="/logo-light.svg" className="dark:hidden" alt="Logo" />
<img src="/logo-dark.svg" className="hidden dark:block" alt="Logo" />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.
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.
type Theme = "light" | "dark" | "system";For next-themes, import ThemeProviderProps from next-themes for full type support.
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 }>;Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥