Implementing Dark Mode with Tailwind CSS
Learn how to implement a robust dark mode system using Tailwind CSS, React Context, and system preference detection for the best user experience.
Dark mode has become a standard feature in modern web applications. Users expect the ability to switch between light and dark themes, and many prefer their interfaces to respect their system preferences. This feature is essential for modern portfolio websites and blog platforms. In this guide, I'll show you how to implement a comprehensive dark mode system using Tailwind CSS and React.
Why Dark Mode Matters
Dark mode isn't just a trendy feature—it offers real benefits:
- Reduced Eye Strain: Especially important for users who spend long hours on screens
- Battery Life: Can extend battery life on OLED displays
- User Preference: Many users simply prefer dark interfaces
- Professional Appeal: Shows attention to modern UX trends
Setting Up Tailwind CSS for Dark Mode
First, ensure your Tailwind configuration supports dark mode:
// tailwind.config.js
module.exports = {
darkMode: 'class', // Enable class-based dark mode
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
// Custom dark mode colors
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
}
}
},
},
plugins: [],
}
Creating the Theme Context
Build a React context to manage theme state across your application:
// src/contexts/ThemeContext.tsx
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } 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: ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const [mounted, setMounted] = useState(false);
// Handle theme resolution and application
useEffect(() => {
const root = window.document.documentElement;
// Remove existing theme classes
root.classList.remove('light', 'dark');
let newResolvedTheme: 'light' | 'dark';
if (theme === 'system') {
// Detect system preference
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
newResolvedTheme = systemTheme;
} else {
newResolvedTheme = theme;
}
setResolvedTheme(newResolvedTheme);
root.classList.add(newResolvedTheme);
// Store preference in localStorage
localStorage.setItem('theme-preference', theme);
}, [theme]);
// Listen for system preference changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (theme === 'system') {
const newResolvedTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newResolvedTheme);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(newResolvedTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
// Initialize theme from localStorage
useEffect(() => {
const stored = localStorage.getItem('theme-preference') as Theme;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
setTheme(stored);
}
setMounted(true);
}, []);
// Prevent hydration mismatch
if (!mounted) {
return <div style={{ visibility: 'hidden' }}>{children}</div>;
}
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Building the Theme Toggle Component
Create a user-friendly toggle component with multiple theme options:
// src/components/ThemeToggle.tsx
'use client';
import { useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext';
interface ThemeToggleProps {
isScrolled?: boolean;
}
export default function ThemeToggle({ isScrolled = false }: ThemeToggleProps) {
const { theme, setTheme, resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const themes = [
{
value: 'light' as const,
label: 'Light',
icon: '☀️',
description: 'Light theme'
},
{
value: 'dark' as const,
label: 'Dark',
icon: '🌙',
description: 'Dark theme'
},
{
value: 'system' as const,
label: 'System',
icon: '💻',
description: 'Follow system preference'
},
];
const currentTheme = themes.find(t => t.value === theme);
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className={`p-2 rounded-lg transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 ${
isScrolled
? 'text-gray-700 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800'
: 'text-white hover:bg-white/10'
}`}
aria-label="Toggle theme"
aria-expanded={isOpen}
>
<span className="text-lg">{currentTheme?.icon}</span>
</button>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown */}
<div className="absolute right-0 top-full mt-2 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 min-w-[180px] overflow-hidden">
{themes.map((themeOption) => (
<button
key={themeOption.value}
onClick={() => {
setTheme(themeOption.value);
setIsOpen(false);
}}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200 flex items-center space-x-3 ${
theme === themeOption.value ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
<span className="text-lg">{themeOption.icon}</span>
<div>
<div className="font-medium">{themeOption.label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{themeOption.description}
</div>
</div>
{theme === themeOption.value && (
<span className="ml-auto text-blue-500">✓</span>
)}
</button>
))}
</div>
</>
)}
</div>
);
}
Styling Components for Dark Mode
Use Tailwind's dark mode classes throughout your components:
// Example: Hero section with dark mode support
export default function Hero() {
return (
<section className="min-h-screen bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800 dark:from-gray-900 dark:via-blue-900 dark:to-gray-800 flex items-center justify-center relative overflow-hidden">
<div className="container mx-auto px-6 text-center relative z-10">
<h1 className="text-5xl md:text-7xl font-bold text-white dark:text-gray-100 mb-6 leading-tight">
Full-Stack Developer
</h1>
<p className="text-xl md:text-2xl text-blue-100 dark:text-gray-300 mb-8 max-w-3xl mx-auto leading-relaxed">
Building modern web applications with cutting-edge technologies
</p>
<button className="bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 px-8 py-4 rounded-full font-semibold hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-300">
View My Work
</button>
</div>
</section>
);
}
Advanced Dark Mode Patterns
1. Image Handling for Dark Mode
Handle images that need different versions for light and dark modes:
interface ResponsiveImageProps {
lightSrc: string;
darkSrc: string;
alt: string;
className?: string;
}
export function ResponsiveImage({ lightSrc, darkSrc, alt, className }: ResponsiveImageProps) {
const { resolvedTheme } = useTheme();
return (
<img
src={resolvedTheme === 'dark' ? darkSrc : lightSrc}
alt={alt}
className={className}
/>
);
}
2. Chart and Data Visualization
Adapt chart colors for dark mode:
const getChartTheme = (isDark: boolean) => ({
backgroundColor: isDark ? '#1e293b' : '#ffffff',
textColor: isDark ? '#e2e8f0' : '#334155',
gridColor: isDark ? '#475569' : '#e2e8f0',
seriesColors: isDark
? ['#3b82f6', '#8b5cf6', '#06b6d4']
: ['#2563eb', '#7c3aed', '#0891b2']
});
3. CSS Custom Properties
Use CSS custom properties for dynamic theming:
/* globals.css */
:root {
--color-primary: 59 130 246; /* blue-500 */
--color-background: 255 255 255;
--color-text: 15 23 42; /* slate-900 */
}
.dark {
--color-primary: 96 165 250; /* blue-400 */
--color-background: 15 23 42; /* slate-900 */
--color-text: 248 250 252; /* slate-50 */
}
.custom-button {
background-color: rgb(var(--color-primary));
color: rgb(var(--color-background));
}
Performance Considerations
1. Preventing Flash of Incorrect Theme
Add a script to prevent theme flash on page load:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme-preference') || 'system';
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
`,
}}
/>
</head>
<body className="transition-colors duration-300">
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
2. Optimizing Theme Transitions
Add smooth transitions for theme changes:
/* globals.css */
* {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
/* Disable transitions during theme change to prevent flicker */
.theme-changing * {
transition: none !important;
}
Testing Dark Mode
1. Manual Testing Checklist
- ✅ Toggle between all three theme modes
- ✅ Refresh page and verify theme persistence
- ✅ Test system preference changes
- ✅ Check all components in both themes
- ✅ Verify contrast ratios meet accessibility standards
2. Automated Testing
// __tests__/theme.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@/contexts/ThemeContext';
import ThemeToggle from '@/components/ThemeToggle';
test('theme toggle switches between themes', () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
const toggleButton = screen.getByLabelText('Toggle theme');
fireEvent.click(toggleButton);
expect(screen.getByText('Light')).toBeInTheDocument();
expect(screen.getByText('Dark')).toBeInTheDocument();
expect(screen.getByText('System')).toBeInTheDocument();
});
Accessibility Considerations
Ensure your dark mode implementation is accessible:
- Proper Contrast: Test color combinations with tools like WebAIM
- Reduced Motion: Respect user preferences for reduced motion
- Focus Indicators: Ensure focus states are visible in both themes
- Screen Readers: Use proper ARIA labels for theme controls
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.01ms !important;
}
}
Conclusion
Implementing dark mode with Tailwind CSS and React provides users with a modern, accessible experience while demonstrating attention to UX details. Key takeaways:
- System Integration: Always respect user system preferences
- Smooth Transitions: Implement proper transitions and prevent theme flash
- Comprehensive Coverage: Apply dark mode styling to all components
- Performance: Optimize for fast theme switching
- Accessibility: Maintain proper contrast and accessibility standards
Dark mode has evolved from a nice-to-have feature to an expected standard. By implementing it thoughtfully with proper state management and smooth transitions, you create a professional, user-friendly experience that sets your application apart.
Looking for more web development tips? Check out my other articles on modern React patterns and performance optimization.

Richard Joseph Porter
Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.