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.

Richard Joseph Porter
8 min read
tailwinddark-modereactcssuxreact-contexttheme-switching

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:

  1. Proper Contrast: Test color combinations with tools like WebAIM
  2. Reduced Motion: Respect user preferences for reduced motion
  3. Focus Indicators: Ensure focus states are visible in both themes
  4. 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:

  1. System Integration: Always respect user system preferences
  2. Smooth Transitions: Implement proper transitions and prevent theme flash
  3. Comprehensive Coverage: Apply dark mode styling to all components
  4. Performance: Optimize for fast theme switching
  5. 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 - Professional headshot

Richard Joseph Porter

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