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.
