Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
14 minLesson 30 of 33
Styling & UI

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

Get Notes Free →
!