Dark Mode Implementation
Dark Mode Implementation
Dark mode done right feels seamless: it respects the user's system preference by default, allows manual override, persists the choice, and doesn't flash the wrong theme on page load. This lesson walks through a complete implementation with next-themes.
next-themes — The Right Tool
Building dark mode from scratch is error-prone — server/client hydration mismatches, flash of wrong theme (FOUC), localStorage timing issues. next-themes solves all of these:
npm install next-themes
Setup
// src/providers/theme-provider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
// src/app/layout.tsx
import { ThemeProvider } from "@/providers/theme-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class" // Adds class="dark" to <html>
defaultTheme="system" // Follow OS preference by default
enableSystem // Enable system preference detection
disableTransitionOnChange // Prevent color flash during theme change
>
{children}
</ThemeProvider>
</body>
</html>
);
}
suppressHydrationWarning on <html> is required because the class is set on the server as "" and then updated client-side to "dark". Without it, React logs a hydration warning.
The Theme Toggle Component
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch — only render after mount
useEffect(() => setMounted(true), []);
if (!mounted) {
// Render a placeholder with the same size to prevent layout shift
return <div className="w-9 h-9" />;
}
return (
<button
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
className="p-2 rounded-lg border border-gray-200 dark:border-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
>
{resolvedTheme === "dark" ? (
<SunIcon className="w-5 h-5" />
) : (
<MoonIcon className="w-5 h-5" />
)}
</button>
);
}
// Three-way toggle: light / dark / system
function ThemeThreeWayToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
const options: Array<{ value: string; label: string }> = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
return (
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{options.map(({ value, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
theme === value
? "bg-gray-900 dark:bg-white text-white dark:text-gray-900"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
{label}
</button>
))}
</div>
);
}
Tailwind Dark Mode Classes
With the setup above, dark: classes activate when the user's theme is dark:
// Body and backgrounds
<body className="bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100">
// Cards
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6">
// Text hierarchy
<h2 className="text-gray-900 dark:text-white font-bold">Title</h2>
<p className="text-gray-600 dark:text-gray-400">Body text</p>
<span className="text-gray-400 dark:text-gray-500">Muted</span>
// Interactive elements
<button className="bg-blue-600 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400 text-white">
Action
</button>
// Form inputs
<input className="bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 rounded-lg px-3 py-2"/>
// Dividers
<hr className="border-gray-200 dark:border-gray-800"/>
CSS Variables Approach (Shadcn-compatible)
For more systematic dark mode, define CSS variables that change with the theme:
/* src/app/globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--border: 214.3 31.8% 91.4%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
}
}
// tailwind.config.ts
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: "hsl(var(--card))",
border: "hsl(var(--border))",
primary: "hsl(var(--primary))",
muted: "hsl(var(--muted))",
}
Now use semantic colors:
<div className="bg-background text-foreground">
<div className="bg-card border border-border rounded-xl">
<button className="bg-primary text-primary-foreground">
<p className="text-muted-foreground">
These automatically switch between light and dark values when .dark is on <html>. No dark: prefix needed for semantic colors.
Reading Theme in Server Components
Server Components render before the client knows the theme. Don't try to read theme in Server Components:
// ❌ Won't work — Server Components can't read client-side theme
async function ServerComponent() {
const theme = ???; // Unknown on server
}
// ✅ Pass dark mode styling via cookies (advanced)
// Or just use CSS variables (they work server-side because CSS handles the switching)
// ✅ Or defer theme-specific rendering to Client Components
function ThemeSensitiveContent() {
// This is fine as a Client Component
const { resolvedTheme } = useTheme();
return <img src={resolvedTheme === "dark" ? "/logo-dark.svg" : "/logo.svg"} alt="Logo"/>;
}
Next lesson: Testing React with Vitest and Testing Library — writing confident tests for your components.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises