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
// 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
// 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
// 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
// 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