Skip to content
← Back
19 min read

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

  1. Level 1: Pure CSS with Manual Class Toggle
  2. Level 2: LocalStorage Persistence
  3. Level 3: System Preference Detection
  4. Level 4: Prevent Flash of Unstyled Content (FOUC)
  5. Level 5: Data Attributes Approach
  6. Level 6: Vanilla JavaScript Module Pattern
  7. Level 7: React with Context API
  8. Level 8: React with Custom Hook
  9. Level 9: Next.js with next-themes
  10. Level 10: Tailwind CSS Integration
  11. 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:

  1. ✅ App respects system theme on initial load
  2. ✅ Clicking toggle switches between light/dark
  3. ❌ After clicking toggle, changing OS theme does nothing
  4. ❌ 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 absolute positioning 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

LevelComplexityPersistenceSystem PrefNo FOUCFrameworkProduction Ready
1. Pure CSSNone
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

  1. Always prevent FOUC - Use inline script in <head> or SSR
  2. Respect system preferences - Check prefers-color-scheme
  3. Let users override - Save explicit choices to localStorage
  4. Use CSS variables - Most flexible and performant
  5. Add smooth transitions - Better UX with CSS transitions
  6. Consider accessibility - Support high contrast, reduced motion
  7. Test thoroughly - Check system changes, persistence, hydration
  8. Provide all three modes - Light, Dark, and System options
  9. Show clear feedback - Icons or labels indicating current theme
  10. Handle edge cases - Hydration mismatches, missing localStorage, etc.

Resources


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! 🎨