Master the Art of Personalization: Build a React & Tailwind Theme Switcher

Master the Art of Personalization: Build a React & Tailwind Theme Switcher

Source: Dev.to

Why This Matters in Real Projects ## Let's Build This Thing ## Step 1: Set Up Your React & Tailwind Project ## Step 2: Create the Theme Context ## Step 3: Build the Theme Switcher Component ## Step 4: Put It All Together ## What Most Tutorials Miss ## Performance Considerations ## Key Takeaways 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. 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. First things first, let's get a fresh React project with Tailwind CSS configured. Now, configure tailwind.config.js to enable dark mode: Here's where the magic happens. We'll use React Context to manage our theme state globally. 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. 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: 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 Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: npx create-react-app theme-switcher-app cd theme-switcher-app npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: npx create-react-app theme-switcher-app cd theme-switcher-app npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p CODE_BLOCK: npx create-react-app theme-switcher-app cd theme-switcher-app npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p CODE_BLOCK: // 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: [], } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // 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: [], } CODE_BLOCK: // 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: [], } COMMAND_BLOCK: // 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; }; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // 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; }; COMMAND_BLOCK: // 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; }; COMMAND_BLOCK: // 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; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // 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; COMMAND_BLOCK: // 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; CODE_BLOCK: // 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; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // 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; CODE_BLOCK: // 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; CODE_BLOCK: <script> const theme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.classList.add(theme); </script> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: <script> const theme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.classList.add(theme); </script> CODE_BLOCK: <script> const theme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.classList.add(theme); </script> - 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