Dark Mode Tutorial

Seamless Dark Mode

Build elegant dark mode experiences that respect user preferences and enhance accessibility

45 min
Duration
Beginner
Level
4
Steps

Live Demo

This tutorial will teach you to create theme switching that works seamlessly with system preferences and user choices.

Implementation Steps

1

Configure Tailwind CSS

Set up dark mode support in your Tailwind configuration

10 minutes
// tailwind.config.ts
module.exports = {
  darkMode: 'class', // Enable class-based dark mode
  content: [
    './src/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      colors: {
        // Custom dark mode color palette
        dark: {
          50: '#f8fafc',
          100: '#f1f5f9',
          200: '#e2e8f0',
          300: '#cbd5e1',
          400: '#94a3b8',
          500: '#64748b',
          600: '#475569',
          700: '#334155',
          800: '#1e293b',
          900: '#0f172a',
        }
      }
    },
  },
  plugins: [],
}
2

Create Theme Provider

Build a context provider for theme management

15 minutes
// contexts/ThemeContext.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  resolvedTheme: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('system');
  const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');

  useEffect(() => {
    // Load theme from localStorage
    const savedTheme = localStorage.getItem('theme') as Theme;
    if (savedTheme) {
      setTheme(savedTheme);
    }
  }, []);

  useEffect(() => {
    const root = window.document.documentElement;
    
    const getSystemTheme = () => {
      return window.matchMedia('(prefers-color-scheme: dark)').matches 
        ? 'dark' 
        : 'light';
    };

    let actualTheme: 'light' | 'dark';
    
    if (theme === 'system') {
      actualTheme = getSystemTheme();
      
      // Listen for system theme changes
      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
      const handleChange = () => {
        setResolvedTheme(getSystemTheme());
      };
      
      mediaQuery.addEventListener('change', handleChange);
      return () => mediaQuery.removeEventListener('change', handleChange);
    } else {
      actualTheme = theme;
    }

    setResolvedTheme(actualTheme);
    
    // Apply theme to document
    root.classList.remove('light', 'dark');
    root.classList.add(actualTheme);
    
    // Save to localStorage
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};
3

Theme Toggle Component

Create an interactive theme switcher component

10 minutes
// components/ThemeToggle.tsx
'use client';

import { Moon, Sun, Monitor } from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
import { motion } from 'framer-motion';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  const themes = [
    { name: 'light', icon: Sun, label: 'Light' },
    { name: 'dark', icon: Moon, label: 'Dark' },
    { name: 'system', icon: Monitor, label: 'System' }
  ] as const;

  return (
    <div className="relative inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
      {themes.map((t) => {
        const Icon = t.icon;
        const isActive = theme === t.name;
        
        return (
          <motion.button
            key={t.name}
            onClick={() => setTheme(t.name)}
            className={`relative z-10 flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
              isActive
                ? 'text-gray-900 dark:text-white'
                : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
            }`}
            whileHover={{ scale: 1.05 }}
            whileTap={{ scale: 0.95 }}
          >
            <Icon className="w-4 h-4" />
            <span className="hidden sm:inline">{t.label}</span>
            
            {isActive && (
              <motion.div
                layoutId="activeTheme"
                className="absolute inset-0 bg-white dark:bg-gray-700 rounded-md shadow-sm"
                style={{ zIndex: -1 }}
                transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
              />
            )}
          </motion.button>
        );
      })}
    </div>
  );
}
4

Dark Mode Styling

Apply dark mode styles throughout your application

10 minutes
// Example component with dark mode support
export function Card({ children }: { children: React.ReactNode }) {
  return (
    <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 shadow-sm dark:shadow-gray-900/25">
      {children}
    </div>
  );
}

// Text with dark mode support
export function Text({ children }: { children: React.ReactNode }) {
  return (
    <p className="text-gray-900 dark:text-gray-100">
      {children}
    </p>
  );
}

// Button with dark mode variants
export function Button({ variant = 'primary', children }: { 
  variant?: 'primary' | 'secondary';
  children: React.ReactNode;
}) {
  const variants = {
    primary: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white',
    secondary: 'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
  };
  
  return (
    <button className={`px-4 py-2 rounded-lg transition-colors ${variants[variant]}`}>
      {children}
    </button>
  );
}

Best Practices

Respect User Preferences

  • Default to system preference when possible
  • Remember user selection across sessions
  • Provide easy access to theme controls
  • Test with different system settings

Design Considerations

  • Maintain sufficient contrast ratios
  • Use semantic color tokens
  • Test readability in both modes
  • Consider color-blind accessibility

Technical Implementation

  • Avoid flash of incorrect theme
  • Use CSS custom properties for complex theming
  • Implement smooth transitions
  • Handle SSR considerations properly