When an interviewer asks you to build a theme changer, don't just build a toggle. Realize this is a high-value round testing your knowledge of:
👉 React Context + State Management + UX Principles.
They expect a scalable theme system, clean state management, and excellent user experience features like persistence and system preference integration.
💡 The Clarifying Questions That Prove Your Expertise
Before writing a single line of code, ask these questions. They demonstrate foresight and a senior-level understanding of production requirements:
- Should it persist theme preference? (Requires
localStorage.) - Should it respect system preferences? (Requires
prefers-color-schememedia query handling.) - Should it support more than just light/dark? (Requires an extensible structure like our
THEMESobject.) - Are keyboard shortcuts / full accessibility required? (Requires ARIA attributes.)
Answering "yes" to these elevates your solution from a demo to a deployable feature.
🎨 What We're Building Today
Our final product is a highly functional Theme Changer that ticks every essential box:
- React Context API for global state.
localStorageIntegration for persistence.- System Preference Detection with
prefers-color-scheme. - TypeScript Support for robustness.
- Tailwind CSS integration with
darkMode: 'class'. - Accessibility and Smooth Transitions.
Quick Project Setup
If you don't have one, set up a React project. We'll use TypeScript and Tailwind CSS.
Bash
npx create-react-app my-theme-changer --template typescript
cd my-theme-changer
# Install Tailwind CSS (refer to their official docs)
Create two core files: src/ThemeContext.tsx (Logic) and src/ThemeChanger.tsx (UI).
Step 1 — The Type-Safe Theme Context Provider
Create src/ThemeContext.tsx. This component handles global state, persistence, initial detection, and provides a type-safe API.
TypeScript
import React, { createContext, useContext, useEffect, useState } from "react";
const LIGHT = "light"
const DARK = "dark"
interface ThemeContextType {
toggleTheme: (theme: string) => void;
theme: string;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState(DARK)
useEffect(()=>{
const savedTheme=localStorage.getItem('theme')
if(savedTheme) setTheme(savedTheme)
else{
const sysPref=window.matchMedia("(prefers-color-scheme: dark)").matches ? DARK : LIGHT
setTheme(sysPref)
}
},[])
useEffect(() => {
if (theme === DARK) {
document.querySelector('body').classList.add(DARK)
}
else {
document.querySelector('body').classList.remove(DARK)
}
localStorage.setItem('theme',theme)
}, [theme])
function toggleTheme(theme: string) {
setTheme(theme)
}
return (<ThemeContext.Provider value={{ toggleTheme,theme }}>
{children}
</ThemeContext.Provider>)
}
const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
export { useTheme, ThemeProvider }
Why this initial setup is strong:
- Type Safety: We define
ThemeContextTypeto ensure consumers ofuseThemeknow exactly what data they're getting. - Initialization Logic: The first
useEffecthandles state initialization: checkinglocalStoragefirst, then falling back toprefers-color-scheme. - DOM Manipulation: The second
useEffectdirectly adds/removes thedarkclass from the<body>element, which is critical for Tailwind CSS.
Step 2 — The Enhanced Theme Changer Component
Create src/ThemeChanger.tsx. This is the UI that consumes our context.
TypeScript
import React, { FC } from 'react'
import { useTheme } from './ThemeContext.tsx';
interface ThemeChangerProps {
}
const ThemeChanger: FC<ThemeChangerProps> = () => {
const {toggleTheme,theme}=useTheme()
return (
<div className="flex gap-2 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<button
onClick={()=>toggleTheme('light')}
className={`px-4 py-2 rounded-md transition-all duration-200 ${
theme === 'light'
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
☀️ Light
</button>
<button
onClick={()=>toggleTheme('dark')}
className={`px-4 py-2 rounded-md transition-all duration-200 ${
theme === 'dark'
? 'bg-blue-500 text-white shadow-md'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
🌙 Dark
</button>
</div>
)
}
export default ThemeChanger;
UI/UX Considerations:
- Custom Hook: The clean
const {toggleTheme,theme}=useTheme()access is a best practice. - Conditional Styling: Styles are dynamically applied using template literals to highlight the currently active theme.
- Smoothness: The
transition-all duration-200class ensures state changes are visually smooth.
Step 3 & 4 — Integration and Tailwind Configuration (Critical Step)
For the theme change to affect the entire application, you must wrap your root component with the Provider and correctly configure Tailwind CSS.
File: src/App.jsx
JavaScript
import React from "react";
import { ThemeProvider } from "./ThemeContext.tsx";
import ThemeChanger from "./ThemeChanger.tsx";
function App() {
return (
<ThemeProvider>
<div className="flex items-center justify-center w-screen h-screen bg-blue-100 transition-colors duration-300 dark:bg-gray-900">
<ThemeChanger/>
</div>
</ThemeProvider>
);
}
export default App;
Tailwind Configuration (Don't Skip This)
File: tailwind.config.js
JavaScript
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
darkMode: 'class', // ← This is essential!
theme: {
extend: {},
},
plugins: [],
};
Why darkMode: 'class' is crucial: It tells Tailwind to look for the .dark class on the <body> element (which our context provider adds) before applying any dark: prefixed utility classes (like dark:bg-gray-900).
Step 5 & 6 — Scaling to Multiple Themes (Extensibility)
To support the "Auto" theme and make the system extensible, we'll refactor the context provider to use a centralized THEMES object.
Enhanced Theme Context (Supporting 'Auto')
File: src/ThemeContext.tsx (Updated)
TypeScript
import React, { createContext, useContext, useEffect, useState } from "react";
const THEMES = {
LIGHT: "light",
DARK: "dark",
AUTO: "auto"
} as const;
type Theme = typeof THEMES[keyof typeof THEMES];
interface ThemeContextType {
theme: Theme;
toggleTheme: (theme: Theme) => void;
availableThemes: Theme[];
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// ... (ThemeProvider implementation as provided in your Step 5) ...
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(THEMES.DARK);
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme && Object.values(THEMES).includes(savedTheme)) {
setTheme(savedTheme);
} else {
const sysPref = window.matchMedia("(prefers-color-scheme: dark)").matches
? THEMES.DARK
: THEMES.LIGHT;
setTheme(sysPref);
}
}, []);
useEffect(() => {
const body = document.querySelector('body');
if (!body) return;
// Remove all theme classes
Object.values(THEMES).forEach(themeClass => {
body.classList.remove(themeClass);
});
// Apply current theme
if (theme === THEMES.AUTO) {
const sysPref = window.matchMedia("(prefers-color-scheme: dark)").matches
? THEMES.DARK
: THEMES.LIGHT;
body.classList.add(sysPref);
} else {
body.classList.add(theme);
}
localStorage.setItem('theme', theme);
}, [theme]);
function toggleTheme(newTheme: Theme) {
setTheme(newTheme);
}
const value: ThemeContextType = {
theme,
toggleTheme,
availableThemes: Object.values(THEMES)
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
export { useTheme, ThemeProvider, THEMES };
export type { Theme };
Enhanced Theme Changer (Dynamic & Accessible)
We now use availableThemes to dynamically generate our buttons, ensuring we handle the new AUTO option.
File: src/ThemeChanger.tsx (Final UI)
TypeScript
import React, { FC } from 'react'
import { useTheme, THEMES } from './ThemeContext';
interface ThemeChangerProps {
}
const ThemeChanger: FC<ThemeChangerProps> = ({ }) => {
const { toggleTheme, theme, availableThemes } = useTheme();
const themeConfig = {
[THEMES.LIGHT]: { label: '☀️ Light', color: 'bg-yellow-500' },
[THEMES.DARK]: { label: '🌙 Dark', color: 'bg-gray-800' },
[THEMES.AUTO]: { label: '🔄 Auto', color: 'bg-blue-500' }
};
return (
<div className="flex gap-2 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mr-4">
Theme:
</h3>
{availableThemes.map((themeOption) => (
<button
key={themeOption}
onClick={() => toggleTheme(themeOption)}
className={`px-4 py-2 rounded-md transition-all duration-200 font-medium ${
theme === themeOption
? `${themeConfig[themeOption].color} text-white shadow-md transform scale-105`
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}`}
aria-pressed={theme === themeOption}
aria-label={`Switch to ${themeOption} theme`}
>
{themeConfig[themeOption].label}
</button>
))}
</div>
);
};
export default ThemeChanger;
Step 7 — Global CSS for Smooth Transitions
Add these rules to your global CSS file (index.css or App.css) for a professional, non-jarring user experience.
CSS
/* In your index.css or App.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global theme transition - Applies to all elements */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Helps browsers apply appropriate default colors for light/dark mode */
.dark {
color-scheme: dark;
}
.light {
color-scheme: light;
}
/* Focus styles for accessibility (keyboard navigation) */
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}