AiTechWorlds
AiTechWorlds
Dark mode done right respects the user's system preference by default, allows manual override, persists the choice across visits, and never flashes the wrong theme on page load.
Think of a hotel room's automatic lighting versus a light switch someone has to remember to flip. Done wrong, dark mode is a switch every visitor has to find and set themselves, and it resets the moment they leave. Done right, it's the room reading your preference the instant you walk in and remembering it next time. This lesson walks through a complete implementation with next-themes.
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// 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.
"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>
);
}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"/>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.
Server Components render before the client knows the theme. Don't try to read theme in Server Components:
// Wrong: Won't work — Server Components can't read client-side theme
async function ServerComponent() {
const theme = ???; // Unknown on server
}
// Correct: Pass dark mode styling via cookies (advanced)
// Or just use CSS variables (they work server-side because CSS handles the switching)
// Correct: 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