Back to Blog
Building a Dynamic Dark/Light Theme System with React Context API and Tailwind CSS

Building a Dynamic Dark/Light Theme System with React Context API and Tailwind CSS

Learn how to implement a flexible theme system in React using Context API and Tailwind CSS. Build a scalable solution that supports multiple themes with seamless switching.

Naim HasanNaim Hasan
09-20-2025
6 min

Theme switching has become an essential feature in modern web applications. Users expect the ability to toggle between light and dark modes, and as developers, we want a system that's easy to maintain and extend. Today, I'll show you how to build a robust theme system using React Context API and Tailwind CSS.

Why This Approach Works

Instead of relying on Tailwind's built-in dark mode classes everywhere, we'll create a centralized theme system that:

  • Provides consistent color management across components
  • Makes theme switching instant and smooth
  • Allows easy extension to multiple themes (not just light/dark)
  • Keeps components clean and theme-agnostic

Setting Up the Theme Data

First, let's define our theme colors. Notice how I'm using the same property names in both datasets - this is crucial for consistency:

// lib/themes.js
export const themes = {
  light: {
    // Background colors
    primary: '#ffffff',
    secondary: '#f8fafc',
    accent: '#f1f5f9',
    
    // Text colors
    textPrimary: '#1e293b',
    textSecondary: '#475569',
    textMuted: '#94a3b8',
    
    // Border colors
    border: '#e2e8f0',
    borderHover: '#cbd5e1',
    
    // Component colors
    cardBg: '#ffffff',
    buttonPrimary: '#3b82f6',
    buttonPrimaryText: '#ffffff',
    buttonSecondary: '#f1f5f9',
    buttonSecondaryText: '#475569',
    
    // Status colors
    success: '#10b981',
    warning: '#f59e0b',
    error: '#ef4444',
  },
  
  dark: {
    // Background colors (same property names)
    primary: '#0f172a',
    secondary: '#1e293b',
    accent: '#334155',
    
    // Text colors (same property names)
    textPrimary: '#f8fafc',
    textSecondary: '#cbd5e1',
    textMuted: '#64748b',
    
    // Border colors (same property names)
    border: '#334155',
    borderHover: '#475569',
    
    // Component colors (same property names)
    cardBg: '#1e293b',
    buttonPrimary: '#3b82f6',
    buttonPrimaryText: '#ffffff',
    buttonSecondary: '#334155',
    buttonSecondaryText: '#cbd5e1',
    
    // Status colors (same property names)
    success: '#10b981',
    warning: '#f59e0b',
    error: '#ef4444',
  }
};

Creating the Theme Context

Now let's build our theme context that will manage the current theme state and provide switching functionality:

// contexts/ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { themes } from '../lib/themes';

const ThemeContext = createContext();

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

export const ThemeProvider = ({ children }) => {
  const [currentTheme, setCurrentTheme] = useState('light');
  
  // Load saved theme from localStorage on mount
  useEffect(() => {
    const savedTheme = localStorage.getItem('app-theme');
    if (savedTheme && themes[savedTheme]) {
      setCurrentTheme(savedTheme);
    } else {
      // Detect system preference
      const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      setCurrentTheme(systemPrefersDark ? 'dark' : 'light');
    }
  }, []);
  
  // Save theme to localStorage whenever it changes
  useEffect(() => {
    localStorage.setItem('app-theme', currentTheme);
    
    // Apply theme colors to CSS variables
    const root = document.documentElement;
    const themeColors = themes[currentTheme];
    
    Object.entries(themeColors).forEach(([key, value]) => {
      root.style.setProperty(`--color-${key}`, value);
    });
  }, [currentTheme]);
  
  const toggleTheme = () => {
    setCurrentTheme(prev => prev === 'light' ? 'dark' : 'light');
  };
  
  const setTheme = (themeName) => {
    if (themes[themeName]) {
      setCurrentTheme(themeName);
    }
  };
  
  const value = {
    currentTheme,
    themes: themes[currentTheme],
    toggleTheme,
    setTheme,
    availableThemes: Object.keys(themes)
  };
  
  return (
    
      {children}
    
  );
};

Updating Tailwind Configuration

We need to extend Tailwind to recognize our CSS variables. Update your tailwind.config.js:

// tailwind.config.js
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        // Theme colors using CSS variables
        'theme-primary': 'var(--color-primary)',
        'theme-secondary': 'var(--color-secondary)',
        'theme-accent': 'var(--color-accent)',
        
        'theme-text-primary': 'var(--color-textPrimary)',
        'theme-text-secondary': 'var(--color-textSecondary)',
        'theme-text-muted': 'var(--color-textMuted)',
        
        'theme-border': 'var(--color-border)',
        'theme-border-hover': 'var(--color-borderHover)',
        
        'theme-card': 'var(--color-cardBg)',
        'theme-btn-primary': 'var(--color-buttonPrimary)',
        'theme-btn-primary-text': 'var(--color-buttonPrimaryText)',
        'theme-btn-secondary': 'var(--color-buttonSecondary)',
        'theme-btn-secondary-text': 'var(--color-buttonSecondaryText)',
        
        'theme-success': 'var(--color-success)',
        'theme-warning': 'var(--color-warning)',
        'theme-error': 'var(--color-error)',
      }
    },
  },
  plugins: [],
}

Building Theme-Aware Components

Now let's create components that automatically adapt to theme changes:

Theme Toggle Button

// components/ThemeToggle.jsx
import { useTheme } from '../contexts/ThemeContext';
import { Sun, Moon } from 'lucide-react';

const ThemeToggle = () => {
  const { currentTheme, toggleTheme } = useTheme();
  
  return (
    
  );
};

export default ThemeToggle;

Theme-Aware Card Component

// components/Card.jsx
const Card = ({ title, children, className = '' }) => {
  return (
    
{title && (

{title}

)}
{children}
); }; export default Card;

Themed Button Component

// components/Button.jsx
const Button = ({ children, variant = 'primary', onClick, className = '', ...props }) => {
  const baseClasses = "px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2";
  
  const variants = {
    primary: "bg-theme-btn-primary text-theme-btn-primary-text hover:opacity-90 focus:ring-theme-btn-primary",
    secondary: "bg-theme-btn-secondary text-theme-btn-secondary-text hover:bg-theme-accent border border-theme-border focus:ring-theme-border",
  };
  
  return (
    
  );
};

export default Button;

Setting Up the App

Wrap your app with the ThemeProvider and create a sample layout:

// App.jsx
import { ThemeProvider } from './contexts/ThemeContext';
import ThemeToggle from './components/ThemeToggle';
import Card from './components/Card';
import Button from './components/Button';

function App() {
  return (
    
      
{/* Header */}

Theme Demo

{/* Main content */}

This card automatically adapts to the current theme. The colors change seamlessly when you toggle themes.

Success Color
Warning Color
Error Color
); } export default App;

Adding Smooth Transitions

To make theme switching feel polished, add a global CSS transition:

/* index.css */
* {
  transition: background-color 200ms ease, border-color 200ms ease, color 200ms ease;
}

/* Prevent transition on page load */
.preload * {
  transition: none !important;
}

And add this to prevent flash on initial load:

// In your main component's useEffect
useEffect(() => {
  document.body.classList.add('preload');
  setTimeout(() => {
    document.body.classList.remove('preload');
  }, 100);
}, []);

Extending to Multiple Themes

The beauty of this system is its extensibility. Want to add a blue theme? Just extend your themes object:

// lib/themes.js
export const themes = {
  light: { /* ... existing light theme */ },
  dark: { /* ... existing dark theme */ },
  
  // New theme with same property names!
  blue: {
    primary: '#1e3a8a',
    secondary: '#1e40af', 
    accent: '#3b82f6',
    textPrimary: '#ffffff',
    textSecondary: '#cbd5e1',
    textMuted: '#94a3b8',
    border: '#3b82f6',
    borderHover: '#60a5fa',
    cardBg: '#1e40af',
    buttonPrimary: '#fbbf24',
    buttonPrimaryText: '#1e3a8a',
    buttonSecondary: '#3b82f6',
    buttonSecondaryText: '#ffffff',
    success: '#10b981',
    warning: '#f59e0b',
    error: '#ef4444',
  },
  
  ocean: {
    primary: '#0c4a6e',
    secondary: '#075985',
    accent: '#0284c7',
    // ... same property names with ocean colors
  }
};

Then create a theme selector component:

// components/ThemeSelector.jsx
import { useTheme } from '../contexts/ThemeContext';

const ThemeSelector = () => {
  const { currentTheme, setTheme, availableThemes } = useTheme();
  
  return (
    
  );
};

Pro Tips for Theme Management

1. Use Semantic Naming

Instead of blue-500, use buttonPrimary. This makes it easier to maintain consistent meanings across themes.

2. Test Contrast Ratios

Always ensure your text colors meet accessibility standards (WCAG AA: 4.5:1 contrast ratio minimum).

3. Consider System Preferences

Our implementation automatically detects the user's system preference on first visit, providing a better initial experience.

Wrapping Up

This theme system gives you:

  • ✅ Instant theme switching with smooth transitions
  • ✅ Centralized color management
  • ✅ Easy component styling with Tailwind classes
  • ✅ Automatic persistence with localStorage
  • ✅ System preference detection
  • ✅ Infinite extensibility to new themes

The key insight is using consistent property names across all themes. This allows you to add as many themes as you need - whether it's seasonal themes, brand variations, or accessibility-focused high-contrast themes.

In this way, we can set as many theme colors as our need. Simply add new theme objects to your themes configuration, and the entire system automatically supports them. Your components remain unchanged, but users get infinite customization options. It's a scalable solution that grows with your application's needs.

#React#Context API#Tailwind CSS#Dark Mode#Theme System#CSS Variables