Dark mode implementation involves detecting system preferences, persisting user choice, and applying the theme across your app. Here's a production-ready approach.
Theme Provider with system detection:
1import { useState, useEffect, createContext, useContext, ReactNode } from "react";23type Theme = "light" | "dark" | "system";45const ThemeContext = createContext<{6 theme: Theme;7 setTheme: (theme: Theme) => void;8 resolvedTheme: "light" | "dark";9}>(null!);1011export function ThemeProvider({ children }: { children: ReactNode }) {12 const [theme, setTheme] = useState<Theme>(() => {13 if (typeof window === "undefined") return "system";14 return (localStorage.getItem("theme") as Theme) || "system";15 });1617 const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");1819 useEffect(() => {20 const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");2122 const getResolvedTheme = () => {23 if (theme === "system") {24 return mediaQuery.matches ? "dark" : "light";25 }26 return theme;27 };2829 const resolved = getResolvedTheme();30 setResolvedTheme(resolved);31 document.documentElement.classList.toggle("dark", resolved === "dark");32 localStorage.setItem("theme", theme);3334 if (theme === "system") {35 const handler = () => {36 const newResolved = getResolvedTheme();37 setResolvedTheme(newResolved);38 document.documentElement.classList.toggle("dark", newResolved === "dark");39 };40 mediaQuery.addEventListener("change", handler);41 return () => mediaQuery.removeEventListener("change", handler);42 }43 }, [theme]);4445 return (46 <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>47 {children}48 </ThemeContext.Provider>49 );50}5152// Theme toggle component53function ThemeToggle() {54 const { theme, setTheme, resolvedTheme } = useContext(ThemeContext);5556 return (57 <div>58 <button59 onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}60 >61 {resolvedTheme === "dark" ? "Light Mode" : "Dark Mode"}62 </button>63 </div>64 );65}
CSS variables approach:
1:root {2 --bg-primary: #ffffff;3 --bg-secondary: #f5f5f5;4 --text-primary: #000000;5 --text-secondary: #666666;6 --border-color: #e0e0e0;7}89.dark {10 --bg-primary: #1a1a1a;11 --bg-secondary: #2d2d2d;12 --text-primary: #ffffff;13 --text-secondary: #a0a0a0;14 --border-color: #404040;15}1617body {18 background-color: var(--bg-primary);19 color: var(--text-primary);20}
Prevent flash of unstyled content (FOUC):
Add this script in your <head> to apply the theme before React hydrates:
1<script>2 (function() {3 var theme = localStorage.getItem("theme") || "system";4 var isDark = theme === "dark" ||5 (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);6 if (isDark) document.documentElement.classList.add("dark");7 })();8</script>
Tailwind CSS dark mode:
1// tailwind.config.js2module.exports = {3 darkMode: "class",4};56// Usage7function Card() {8 return (9 <div className="bg-white dark:bg-gray-800 text-black dark:text-white">10 <h2 className="text-gray-900 dark:text-gray-100">Title</h2>11 </div>12 );13}
Best practices:
prefers-color-scheme