The Complete Guide to Theme Toggle: From Traditional to Modern
A comprehensive guide covering theme toggle implementations from basic CSS to production-ready Next.js solutions.
Contents
- Table of Contents
- Level 1: Pure CSS with Manual Class Toggle
- HTML
- CSS
- JavaScript
- Level 2: LocalStorage Persistence
- JavaScript
- Level 3: System Preference Detection
- JavaScript
- Level 4: Prevent Flash of Unstyled Content (FOUC)
- HTML (Updated)
- CSS (Updated)
- Level 5: Data Attributes Approach
- HTML
- CSS
- JavaScript
- Level 6: Vanilla JavaScript Module Pattern
- theme.js
- Usage
- Level 7: React with Context API
- ThemeContext.jsx
- App.jsx
- ThemeToggle.jsx
- Prevent FOUC in React
- Level 8: React with Custom Hook
- useTheme.js
- Usage
- Level 9: Next.js with next-themes
- Installation
- app/layout.tsx (App Router)
- components/ThemeToggle.tsx
- Level 10: Tailwind CSS Integration
- tailwind.config.js
- app/layout.tsx
- Usage in Components
- Common Tailwind Dark Mode Patterns
- Next.js Three-Mode Theme: Common Issues & Solutions
- The Problem: System Theme Not Updating
- Root Cause
- Solution 1: Three-Way Cycle Toggle
- Solution 2: Dropdown Menu
- Solution 3: Segmented Control
- Adding Tooltips
- Complete Setup Checklist
- Common Mistakes to Avoid
- Debugging Tips
- Comparison Table
- Recommendations
- For Learning:
- For Simple Sites:
- For React Apps:
- For Next.js:
- For Tailwind Users:
- Best Practices Summary
- Resources
- Conclusion
Table of Contents
- Level 1: Pure CSS with Manual Class Toggle
- Level 2: LocalStorage Persistence
- Level 3: System Preference Detection
- Level 4: Prevent Flash of Unstyled Content (FOUC)
- Level 5: Data Attributes Approach
- Level 6: Vanilla JavaScript Module Pattern
- Level 7: React with Context API
- Level 8: React with Custom Hook
- Level 9: Next.js with next-themes
- Level 10: Tailwind CSS Integration
- Next.js Three-Mode Theme: Common Issues & Solutions
Level 1: Pure CSS with Manual Class Toggle
Let's start theming!
Complexity: ⭐
Features: Basic toggle only
The simplest implementation using vanilla JavaScript.
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Theme Toggle - Basic</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1>Hello World</h1>
<p>This is a simple theme toggle example.</p>
<button id="themeToggle">Toggle Theme</button>
</div>
<script src="script.js"></script>
</body>
</html>
CSS
/* Default Light Theme */
:root {
--bg-color: #ffffff;
--text-color: #000000;
--button-bg: #007bff;
--button-text: #ffffff;
}
/* Dark Theme */
body.dark-theme {
--bg-color: #1a1a1a;
--text-color: #ffffff;
--button-bg: #0056b3;
--button-text: #ffffff;
}
/* Apply variables */
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: Arial, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
margin: 0;
padding: 2rem;
}
.container {
max-width: 800px;
margin: 0 auto;
}
button {
background-color: var(--button-bg);
color: var(--button-text);
border: none;
padding: 0.75rem 1.5rem;
font-size: 1rem;
cursor: pointer;
border-radius: 5px;
transition: background-color 0.3s ease;
}
button:hover {
opacity: 0.9;
}
JavaScript
const themeToggle = document.getElementById('themeToggle');
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark-theme');
});
Pros:
- ✅ Simple and straightforward
- ✅ No dependencies
- ✅ Easy to understand
Cons:
- ❌ Theme doesn't persist on page reload
- ❌ No system preference detection
- ❌ Manual DOM manipulation
Level 2: LocalStorage Persistence
Complexity: ⭐⭐
Features: Persistence across sessions
Add localStorage to remember user preference.
JavaScript
const themeToggle = document.getElementById('themeToggle');
// Function to set theme
function setTheme(theme) {
if (theme === 'dark') {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
localStorage.setItem('theme', theme);
}
// Function to get current theme
function getTheme() {
return localStorage.getItem('theme') || 'light';
}
// Initialize theme on page load
const savedTheme = getTheme();
setTheme(savedTheme);
// Toggle theme on button click
themeToggle.addEventListener('click', () => {
const currentTheme = getTheme();
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
});
Improvements:
- ✅ Theme persists across page reloads
- ✅ Better user experience
Level 3: System Preference Detection
Complexity: ⭐⭐⭐
Features: Respects OS-level theme preference
Detect and respect the user's system theme preference.
JavaScript
const themeToggle = document.getElementById('themeToggle');
// Check if user has a saved preference, otherwise check system preference
function getPreferredTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
return savedTheme;
}
// Check system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
// Set theme
function setTheme(theme) {
if (theme === 'dark') {
document.body.classList.add('dark-theme');
themeToggle.textContent = '☀️ Light Mode';
} else {
document.body.classList.remove('dark-theme');
themeToggle.textContent = '🌙 Dark Mode';
}
localStorage.setItem('theme', theme);
}
// Initialize theme
const preferredTheme = getPreferredTheme();
setTheme(preferredTheme);
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// Only update if user hasn't manually set a preference
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
// Toggle theme
themeToggle.addEventListener('click', () => {
const currentTheme = document.body.classList.contains('dark-theme') ? 'dark' : 'light';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
});
Improvements:
- ✅ Respects system preferences
- ✅ Auto-updates when system preference changes
- ✅ User choice takes precedence
Level 4: Prevent Flash of Unstyled Content (FOUC)
Complexity: ⭐⭐⭐
Features: No theme flash on page load
Eliminate the brief flash of wrong theme before JavaScript runs.
HTML (Updated)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Theme Toggle - No Flash</title>
<!-- Inline script BEFORE stylesheets -->
<script>
// This runs immediately, before page renders
(function() {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
if (theme === 'dark') {
document.documentElement.classList.add('dark-theme');
}
})();
</script>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1>No Flash on Load!</h1>
<button id="themeToggle">Toggle Theme</button>
</div>
<script src="script.js"></script>
</body>
</html>
CSS (Updated)
/* Apply to html instead of body */
:root {
--bg-color: #ffffff;
--text-color: #000000;
}
html.dark-theme {
--bg-color: #1a1a1a;
--text-color: #ffffff;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
transition: background-color 0.3s ease, color 0.3s ease;
}
Key Change: Apply theme class to <html> instead of <body> so it's set before the page renders.
Improvements:
- ✅ No flash of incorrect theme
- ✅ Instant theme application
Level 5: Data Attributes Approach
Complexity: ⭐⭐⭐
Features: More semantic, easier to extend
Use data attributes instead of classes for better semantics.
HTML
<html lang="en" data-theme="light">
CSS
/* Light theme (default) */
:root,
[data-theme="light"] {
--bg-color: #ffffff;
--text-color: #000000;
}
/* Dark theme */
[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #ffffff;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
JavaScript
const themeToggle = document.getElementById('themeToggle');
const html = document.documentElement;
function setTheme(theme) {
html.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
function getTheme() {
const saved = localStorage.getItem('theme');
if (saved) return saved;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
// Initialize
setTheme(getTheme());
// Toggle
themeToggle.addEventListener('click', () => {
const current = html.getAttribute('data-theme');
const newTheme = current === 'light' ? 'dark' : 'light';
setTheme(newTheme);
});
Benefits:
- ✅ More semantic (data attributes describe state)
- ✅ Easier to add more themes (e.g.,
data-theme="blue") - ✅ Better for debugging (inspect element shows theme clearly)
Level 6: Vanilla JavaScript Module Pattern
Complexity: ⭐⭐⭐⭐
Features: Organized, reusable code
Better code organization using the module pattern.
theme.js
// Theme Manager Module
const ThemeManager = (() => {
const THEME_KEY = 'theme';
const THEME_ATTR = 'data-theme';
const THEMES = {
LIGHT: 'light',
DARK: 'dark'
};
// Private variables
let currentTheme = THEMES.LIGHT;
let callbacks = [];
// Private methods
function getSystemTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? THEMES.DARK : THEMES.LIGHT;
}
function getSavedTheme() {
return localStorage.getItem(THEME_KEY);
}
function applyTheme(theme) {
document.documentElement.setAttribute(THEME_ATTR, theme);
currentTheme = theme;
// Trigger callbacks
callbacks.forEach(callback => callback(theme));
}
function saveTheme(theme) {
localStorage.setItem(THEME_KEY, theme);
}
// Public API
return {
init() {
const savedTheme = getSavedTheme();
const theme = savedTheme || getSystemTheme();
this.setTheme(theme);
// Listen for system changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!getSavedTheme()) {
this.setTheme(e.matches ? THEMES.DARK : THEMES.LIGHT);
}
});
},
setTheme(theme) {
if (!Object.values(THEMES).includes(theme)) {
console.warn(`Invalid theme: ${theme}`);
return;
}
applyTheme(theme);
saveTheme(theme);
},
toggleTheme() {
const newTheme = currentTheme === THEMES.LIGHT ? THEMES.DARK : THEMES.LIGHT;
this.setTheme(newTheme);
},
getTheme() {
return currentTheme;
},
onChange(callback) {
callbacks.push(callback);
},
removeCallback(callback) {
callbacks = callbacks.filter(cb => cb !== callback);
}
};
})();
// Export for use
export default ThemeManager;
Usage
import ThemeManager from './theme.js';
// Initialize
ThemeManager.init();
// Set up toggle button
document.getElementById('themeToggle').addEventListener('click', () => {
ThemeManager.toggleTheme();
});
// Listen for theme changes
ThemeManager.onChange((theme) => {
console.log(`Theme changed to: ${theme}`);
// Update UI, analytics, etc.
});
Benefits:
- ✅ Encapsulated logic
- ✅ Event system for theme changes
- ✅ Reusable across projects
Level 7: React with Context API
Complexity: ⭐⭐⭐⭐
Features: React integration, global state
Modern React implementation using Context API.
ThemeContext.jsx
import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext();
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
useEffect(() => {
// Get saved or system preference
const saved = localStorage.getItem('theme');
if (saved) {
setTheme(saved);
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(prefersDark ? 'dark' : 'light');
}
}, []);
useEffect(() => {
// Apply theme to document
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
useEffect(() => {
// Listen for system preference changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
App.jsx
import React from 'react';
import { ThemeProvider } from './ThemeContext';
import ThemeToggle from './ThemeToggle';
function App() {
return (
<ThemeProvider>
<div className="app">
<h1>React Theme Toggle</h1>
<ThemeToggle />
</div>
</ThemeProvider>
);
}
export default App;
ThemeToggle.jsx
import React from 'react';
import { useTheme } from './ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙 Dark Mode' : '☀️ Light Mode'}
</button>
);
}
export default ThemeToggle;
Prevent FOUC in React
<!-- In public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Prevent flash of unstyled content -->
<script>
(function() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<title>React Theme Toggle</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
Level 8: React with Custom Hook
Complexity: ⭐⭐⭐
Features: Reusable hook, simpler than Context
More lightweight approach using a custom hook.
useTheme.js
import { useState, useEffect } from 'react';
export const useTheme = () => {
const [theme, setTheme] = useState(() => {
// Initialize from localStorage or system preference
const saved = localStorage.getItem('theme');
if (saved) return saved;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
});
useEffect(() => {
// Apply theme
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
useEffect(() => {
// Listen for system changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return { theme, setTheme, toggleTheme };
};
Usage
import React from 'react';
import { useTheme } from './useTheme';
function App() {
const { theme, toggleTheme } = useTheme();
return (
<div className="app">
<h1>Custom Hook Theme Toggle</h1>
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
</div>
);
}
Level 9: Next.js with next-themes
Complexity: ⭐⭐
Features: SSR/SSG support, no flash, production-ready
The professional solution for Next.js applications.
Installation
npm install next-themes
app/layout.tsx (App Router)
import { ThemeProvider } from 'next-themes';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem={true}
>
{children}
</ThemeProvider>
</body>
</html>
);
}
components/ThemeToggle.tsx
'use client';
import { useTheme } from 'next-themes';
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
// Prevent hydration mismatch
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '🌙 Dark' : '☀️ Light'}
</button>
);
}
Benefits:
- ✅ Handles SSR/SSG automatically
- ✅ No flash of unstyled content
- ✅ System preference detection built-in
- ✅ Multiple theme support
- ✅ Class or attribute-based
- ✅ Small bundle size (~1.5KB)
Level 10: Tailwind CSS Integration
Complexity: ⭐⭐
Features: Tailwind dark mode utilities
Modern styling with Tailwind's dark: prefix.
tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class', // Enable class-based dark mode
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}
app/layout.tsx
import { ThemeProvider } from 'next-themes';
import './globals.css';
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}
Usage in Components
export default function Card() {
return (
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
<h2 className="text-xl font-bold">Card Title</h2>
<p className="text-gray-600 dark:text-gray-300">Card content</p>
<button className="bg-blue-500 dark:bg-blue-600 hover:bg-blue-600 dark:hover:bg-blue-700 text-white px-4 py-2 rounded">
Button
</button>
</div>
);
}
Common Tailwind Dark Mode Patterns
// Background colors
<div className="bg-white dark:bg-gray-900">
// Text colors
<p className="text-gray-900 dark:text-white">
// Borders
<div className="border border-gray-200 dark:border-gray-700">
// Hover states
<button className="hover:bg-gray-100 dark:hover:bg-gray-800">
// Gradients
<div className="bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-700 dark:to-purple-700">
// Focus states
<input className="focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400">
// Group hover (parent affects children)
<div className="group">
<p className="text-gray-600 group-hover:text-gray-900 dark:group-hover:text-white">
</div>
Next.js Three-Mode Theme: Common Issues & Solutions
The Problem: System Theme Not Updating
When implementing a three-mode theme toggle (Light, Dark, System) in Next.js, users often encounter this issue:
Symptoms:
- ✅ App respects system theme on initial load
- ✅ Clicking toggle switches between light/dark
- ❌ After clicking toggle, changing OS theme does nothing
- ❌ App no longer follows system preference
Root Cause
The toggle button converts "system" to explicit "light" or "dark", breaking the connection to OS preferences.
// ❌ WRONG - This breaks system tracking
const handleThemeToggle = () => {
const isDark = resolvedTheme === "dark"
const nextTheme = isDark ? "light" : "dark" // Only toggles between light/dark
setTheme(nextTheme)
}
// What happens:
// 1. User has theme = "system"
// 2. User clicks toggle → theme becomes "light" or "dark"
// 3. localStorage now has "light" or "dark", NOT "system"
// 4. OS changes are ignored because theme is hardcoded
Solution 1: Three-Way Cycle Toggle
Make the toggle cycle through all three modes: Light → Dark → System
'use client'
import * as React from "react"
import { Moon, Sun, Monitor } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { setTheme, theme, resolvedTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full">
<div className="h-4 w-4" />
</Button>
)
}
const handleThemeToggle = () => {
// ✅ CORRECT - Cycle through all three modes
const nextTheme =
theme === 'light' ? 'dark' :
theme === 'dark' ? 'system' :
'light'
setTheme(nextTheme)
}
return (
<Button
variant="ghost"
size="icon"
onClick={handleThemeToggle}
className="h-9 w-9 rounded-full"
aria-label={`Theme: ${theme}`}
title={theme === 'system' ? `System (currently ${resolvedTheme})` : theme}
>
{theme === 'system' ? (
<Monitor className="h-[1.2rem] w-[1.2rem]" />
) : (
<>
<Sun
className={`h-[1.2rem] w-[1.2rem] transition-all duration-500 ${
theme === "light"
? "rotate-0 scale-100 opacity-100"
: "-rotate-90 scale-0 opacity-0"
}`}
/>
<Moon
className={`absolute h-[1.2rem] w-[1.2rem] transition-all duration-500 ${
theme === "dark"
? "rotate-0 scale-100 opacity-100"
: "rotate-90 scale-0 opacity-0"
}`}
/>
</>
)}
</Button>
)
}
Why absolute on Moon:
- The
absolutepositioning removes the Moon icon from normal document flow - Both icons occupy the exact same visual space
- This enables smooth crossfade/morph animation
- Without it, icons would stack vertically and button would be taller
Solution 2: Dropdown Menu
More explicit UI with three separate options:
'use client'
import { useTheme } from "next-themes"
import { Moon, Sun, Monitor } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
{theme === "light" && <span className="ml-auto">✓</span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
{theme === "dark" && <span className="ml-auto">✓</span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span>System</span>
{theme === "system" && <span className="ml-auto">✓</span>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
Solution 3: Segmented Control
Three buttons side-by-side:
'use client'
import { useTheme } from "next-themes"
import { Moon, Sun, Monitor } from "lucide-react"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => setMounted(true), [])
if (!mounted) return null
const themes = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
]
return (
<div className="inline-flex gap-1 p-1 bg-muted rounded-lg">
{themes.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`
px-3 py-2 rounded-md text-sm font-medium transition-all
${theme === value ? 'bg-background shadow-sm' : 'hover:bg-muted-foreground/10'}
`}
aria-label={label}
title={label}
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
)
}
Adding Tooltips
Show helpful tooltips on hover:
npx shadcn-ui@latest add tooltip
'use client'
import { useTheme } from "next-themes"
import { Moon, Sun, Monitor } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function ThemeToggle() {
const { setTheme, theme, resolvedTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => setMounted(true), [])
if (!mounted) return null
const handleThemeToggle = () => {
const nextTheme =
theme === 'light' ? 'dark' :
theme === 'dark' ? 'system' :
'light'
setTheme(nextTheme)
}
const getTooltipText = () => {
if (theme === 'system') {
return `System (${resolvedTheme})`
}
return theme === 'light' ? 'Light mode' : 'Dark mode'
}
return (
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleThemeToggle}
className="h-9 w-9 rounded-full"
>
{theme === 'system' ? (
<Monitor className="h-[1.2rem] w-[1.2rem]" />
) : (
<>
<Sun className={`h-[1.2rem] w-[1.2rem] transition-all ${theme === "light" ? "rotate-0 scale-100" : "-rotate-90 scale-0"}`} />
<Moon className={`absolute h-[1.2rem] w-[1.2rem] transition-all ${theme === "dark" ? "rotate-0 scale-100" : "rotate-90 scale-0"}`} />
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{getTooltipText()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
Complete Setup Checklist
1. Install next-themes:
npm install next-themes
2. Configure Tailwind (tailwind.config.js):
module.exports = {
darkMode: 'class', // Required!
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
// ...
}
3. Set up ThemeProvider (app/layout.tsx):
import { ThemeProvider } from 'next-themes'
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem={true}
>
{children}
</ThemeProvider>
</body>
</html>
)
}
4. Create toggle component (use one of the solutions above)
5. Test:
- ✅ App should follow system theme initially
- ✅ Toggle should cycle: Light → Dark → System
- ✅ When on "System", OS changes update the app
- ✅ When on "Light" or "Dark", OS changes are ignored
- ✅ Theme persists across page reloads
Common Mistakes to Avoid
❌ Don't call setTheme() based on resolvedTheme:
// ❌ WRONG - Breaks system tracking
React.useEffect(() => {
setTheme(resolvedTheme || 'light')
}, [resolvedTheme])
❌ Don't only toggle between light/dark:
// ❌ WRONG - Loses "system" option
const handleToggle = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
✅ Do cycle through all three modes:
// ✅ CORRECT
const handleToggle = () => {
const nextTheme =
theme === 'light' ? 'dark' :
theme === 'dark' ? 'system' :
'light'
setTheme(nextTheme)
}
✅ Do use theme (not resolvedTheme) for cycling:
// ✅ CORRECT - Preserves "system" state
if (theme === 'system') { /* ... */ }
// ❌ WRONG - "system" is never detected
if (resolvedTheme === 'system') { /* ... */ }
Debugging Tips
Check localStorage:
// In browser console
console.log(localStorage.getItem('theme'))
// Should be: "system", "light", or "dark"
// If it's missing "system" option, that's the bug
Reset to system:
localStorage.setItem('theme', 'system')
location.reload()
Debug component:
'use client'
import { useTheme } from "next-themes"
import { useEffect, useState } from "react"
export function ThemeDebug() {
const { theme, resolvedTheme, systemTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return (
<div className="fixed bottom-4 right-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-xs">
<p><strong>theme:</strong> {theme}</p>
<p><strong>resolvedTheme:</strong> {resolvedTheme}</p>
<p><strong>systemTheme:</strong> {systemTheme}</p>
</div>
)
}
Comparison Table
| Level | Complexity | Persistence | System Pref | No FOUC | Framework | Production Ready |
|---|---|---|---|---|---|---|
| 1. Pure CSS | ⭐ | ❌ | ❌ | ❌ | None | ❌ |
| 2. localStorage | ⭐⭐ | ✅ | ❌ | ❌ | None | ⚠️ |
| 3. System Pref | ⭐⭐⭐ | ✅ | ✅ | ❌ | None | ⚠️ |
| 4. No FOUC | ⭐⭐⭐ | ✅ | ✅ | ✅ | None | ✅ |
| 5. Data Attrs | ⭐⭐⭐ | ✅ | ✅ | ✅ | None | ✅ |
| 6. Module Pattern | ⭐⭐⭐⭐ | ✅ | ✅ | ✅ | None | ✅ |
| 7. React Context | ⭐⭐⭐⭐ | ✅ | ✅ | ✅ | React | ✅ |
| 8. React Hook | ⭐⭐⭐ | ✅ | ✅ | ✅ | React | ✅ |
| 9. next-themes | ⭐⭐ | ✅ | ✅ | ✅ | Next.js | ✅✅ |
| 10. Tailwind | ⭐⭐ | ✅ | ✅ | ✅ | Tailwind | ✅✅ |
Recommendations
For Learning:
Start with Level 1-4 to understand the fundamentals.
For Simple Sites:
Use Level 4 or 5 (Vanilla JS with FOUC prevention).
For React Apps:
Use Level 8 (Custom Hook) for simplicity.
For Next.js:
Use Level 9 (next-themes) - it's production-ready and handles all edge cases.
For Tailwind Users:
Combine Level 9 + Level 10 for the best developer experience.
Best Practices Summary
- Always prevent FOUC - Use inline script in
<head>or SSR - Respect system preferences - Check
prefers-color-scheme - Let users override - Save explicit choices to localStorage
- Use CSS variables - Most flexible and performant
- Add smooth transitions - Better UX with CSS transitions
- Consider accessibility - Support high contrast, reduced motion
- Test thoroughly - Check system changes, persistence, hydration
- Provide all three modes - Light, Dark, and System options
- Show clear feedback - Icons or labels indicating current theme
- Handle edge cases - Hydration mismatches, missing localStorage, etc.
Resources
- MDN: prefers-color-scheme
- next-themes Documentation
- Tailwind CSS Dark Mode
- React Context API
- Web.dev: Dark Mode
- CSS Working Group: Viewport Units
Conclusion
Theme toggling has evolved from simple class manipulation to sophisticated systems that respect user preferences, prevent visual glitches, and integrate seamlessly with modern frameworks. The key is choosing the right level of complexity for your project while following best practices for accessibility and user experience.
For most modern web applications, next-themes with Tailwind CSS provides the best balance of functionality, developer experience, and production-readiness.
Happy theming! 🎨