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-scheme media query handling.)
  • Should it support more than just light/dark? (Requires an extensible structure like our THEMES object.)
  • 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.
  • localStorage Integration for persistence.
  • System Preference Detection with prefers-color-scheme.
  • TypeScript Support for robustness.
  • Tailwind CSS integration with darkMode: 'class'.
  • Accessibility and Smooth Transitions.

Screenshot 2025-10-02 at 10.10.58 PM.png

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 ThemeContextType to ensure consumers of useTheme know exactly what data they're getting.
  • Initialization Logic: The first useEffect handles state initialization: checking localStorage first, then falling back to prefers-color-scheme.
  • DOM Manipulation: The second useEffect directly adds/removes the dark class 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-200 class 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;
}


View full code here