Ever landed on a website late at night, only to be blinded by its default bright white interface? Or perhaps you're building an app and want to offer a premium, customizable feel? User personalization isn't just a "nice-to-have" anymore; it's an expectation. And one of the most impactful ways to offer it is through a theme switcher.
In my experience building enterprise dashboards, I've found that a well-implemented theme switcher significantly improves user satisfaction. Let me show you how to build one the right way.
Why This Matters in Real Projects
Imagine you're building the next big social media app. You've got sleek profiles, an intuitive feed, and a vibrant community. But then, users start asking: "Can we have a dark mode?"
This is where a well-architected theme switcher comes in. We'll leverage React's Context API to create a global theme state that any component can access, and Tailwind CSS's dark: variant to effortlessly apply theme-specific styles. We'll even persist the user's choice in localStorage, so their preference is remembered on subsequent visits.
Let's Build This Thing
Step 1: Set Up Your React & Tailwind Project
First things first, let's get a fresh React project with Tailwind CSS configured.
npx create-react-app theme-switcher-app
cd theme-switcher-app
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Now, configure tailwind.config.js to enable dark mode:
// tailwind.config.js
module.exports = {
darkMode: 'class', // We'll toggle a 'dark' class on the HTML element
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
}
Step 2: Create the Theme Context
Here's where the magic happens. We'll use React Context to manage our theme state globally.
// src/contexts/ThemeContext.tsx
import React, { createContext, useState, useEffect, useContext } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
// Check localStorage first, then system preference
const stored = localStorage.getItem('theme') as Theme;
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
useEffect(() => {
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
Step 3: Build the Theme Switcher Component
// src/components/ThemeSwitcher.tsx
import { useTheme } from '../contexts/ThemeContext';
const ThemeSwitcher = () => {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="p-2 rounded-full bg-gray-200 dark:bg-gray-800
text-gray-800 dark:text-gray-200
shadow-md transition-colors duration-300"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
};
export default ThemeSwitcher;
Step 4: Put It All Together
// src/App.tsx
import { ThemeProvider } from './contexts/ThemeContext';
import ThemeSwitcher from './components/ThemeSwitcher';
function App() {
return (
<ThemeProvider>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900
text-gray-900 dark:text-gray-100
transition-colors duration-300">
<nav className="p-4 flex justify-end">
<ThemeSwitcher />
</nav>
<main className="container mx-auto p-8">
<h1 className="text-4xl font-bold mb-4">
Welcome to My Themed App
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400">
Toggle the theme and watch the magic happen.
</p>
</main>
</div>
</ThemeProvider>
);
}
export default App;
What Most Tutorials Miss
Here's something I learned the hard way: always respect system preferences first. Notice how our context checks window.matchMedia('(prefers-color-scheme: dark)') before defaulting? This small detail makes your app feel native and respectful of user settings.
Another pitfall: don't forget the transition-colors duration-300 class. Without smooth transitions, theme switching feels jarring and unprofessional.
Performance Considerations
One thing to watch out for: if you're using server-side rendering (Next.js, for example), you'll get a flash of unstyled content (FOUC) because localStorage isn't available on the server. The solution? Add a small script in your <head> that runs before React hydrates:
<script>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.add(theme);
</script>
Key Takeaways
- Use React Context for global theme state — it's cleaner than prop drilling
- Tailwind's
dark:variant makes theming almost effortless - Always persist preferences in localStorage for returning users
- Respect system preferences as the initial default
- Add smooth transitions for a polished feel
Now go ahead and give your users the power to choose. They'll thank you for it.
✨ Let's keep the conversation going!
If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.
✍️ Read more on my blog: bishoy-bishai.github.io
☕ Let's chat on LinkedIn: linkedin.com/in/bishoybishai
Top comments (0)