Theme Management in React: Building Scalable Design Systems That Actually Work
Building a scalable theme system in React isn't just about switching between light and dark modes. In my experience working with design systems across multiple teams, the real challenge lies in creating a flexible, performant architecture that grows with your application while maintaining consistency and developer experience.
I've implemented theme systems for applications serving millions of users, and learned that the most successful approaches combine modern React patterns with CSS fundamentals. Let me walk you through building a theme management system that actually scales.
The Foundation: Design Tokens and Type Safety
The biggest mistake I see teams make is starting with colors instead of starting with structure. Before writing any React code, establish your design tokens as the single source of truth.
// tokens/base.ts
export const spacing = {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
'2xl': '3rem',
} as const;
export const typography = {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['Fira Code', 'monospace'],
},
fontSize: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
},
fontWeight: {
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
},
} as const;
export const breakpoints = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
} as const;
// tokens/colors.ts
export const lightColors = {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
900: '#0c4a6e',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
500: '#6b7280',
900: '#111827',
},
background: {
primary: '#ffffff',
secondary: '#f9fafb',
elevated: '#ffffff',
},
text: {
primary: '#111827',
secondary: '#6b7280',
inverse: '#ffffff',
},
} as const;
export const darkColors = {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#38bdf8',
600: '#0ea5e9',
900: '#0c4a6e',
},
gray: {
50: '#f9fafb',
100: '#374151',
500: '#9ca3af',
900: '#f3f4f6',
},
background: {
primary: '#0f1419',
secondary: '#1f2937',
elevated: '#374151',
},
text: {
primary: '#f3f4f6',
secondary: '#9ca3af',
inverse: '#111827',
},
} as const;
export type ColorTokens = typeof lightColors;
// tokens/index.ts
import { spacing, typography, breakpoints } from './base';
import { lightColors, darkColors, type ColorTokens } from './colors';
export const baseTokens = {
spacing,
typography,
breakpoints,
} as const;
export const lightTheme = {
...baseTokens,
colors: lightColors,
} as const;
export const darkTheme = {
...baseTokens,
colors: darkColors,
} as const;
export type Theme = typeof lightTheme;
export type ThemeMode = 'light' | 'dark';
export { lightColors, darkColors, type ColorTokens };
This token structure gives you type safety, consistent naming, and makes it easy to add new themes later. I've found that starting with this foundation prevents the chaos that comes from ad-hoc color additions.
Building the Theme Context
Here's where most tutorials get it wrong. They create a single context that causes unnecessary re-renders. Instead, separate your theme state from your theme values.
// context/ThemeContext.tsx
import { createContext, useContext, ReactNode } from 'react';
import { Theme, ThemeMode, lightTheme, darkTheme } from '../tokens';
interface ThemeContextValue {
theme: Theme;
mode: ThemeMode;
}
interface ThemeControlContextValue {
setMode: (mode: ThemeMode) => void;
toggleMode: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: lightTheme,
mode: 'light',
});
const ThemeControlContext = createContext<ThemeControlContextValue>({
setMode: () => {},
toggleMode: () => {},
});
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
export const useThemeControl = () => {
const context = useContext(ThemeControlContext);
if (!context) {
throw new Error('useThemeControl must be used within ThemeProvider');
}
return context;
};
The separation means components that only need to read theme values won't re-render when the mode changes, and components that trigger mode changes won't re-render when theme values are accessed.
The Theme Provider Implementation
// providers/ThemeProvider.tsx
import { useState, useEffect, useMemo, ReactNode } from 'react';
import { ThemeContext, ThemeControlContext } from '../context/ThemeContext';
import { lightTheme, darkTheme, ThemeMode } from '../tokens';
interface ThemeProviderProps {
children: ReactNode;
defaultMode?: ThemeMode;
storageKey?: string;
}
export const ThemeProvider = ({
children,
defaultMode = 'light',
storageKey = 'theme-mode'
}: ThemeProviderProps) => {
const [mode, setModeState] = useState<ThemeMode>(() => {
if (typeof window === 'undefined') return defaultMode;
const stored = localStorage.getItem(storageKey);
if (stored && (stored === 'light' || stored === 'dark')) {
return stored;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
const theme = useMemo(() =>
mode === 'dark' ? darkTheme : lightTheme,
[mode]
);
const setMode = useMemo(() => (newMode: ThemeMode) => {
setModeState(newMode);
localStorage.setItem(storageKey, newMode);
}, [storageKey]);
const toggleMode = useMemo(() => () => {
setMode(mode === 'light' ? 'dark' : 'light');
}, [mode, setMode]);
const themeValue = useMemo(() => ({ theme, mode }), [theme, mode]);
const controlValue = useMemo(() => ({ setMode, toggleMode }), [setMode, toggleMode]);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem(storageKey)) {
setModeState(e.matches ? 'dark' : 'light');
}
};
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
}, [storageKey]);
useEffect(() => {
document.documentElement.setAttribute('data-theme', mode);
}, [mode]);
return (
<ThemeContext.Provider value={themeValue}>
<ThemeControlContext.Provider value={controlValue}>
{children}
</ThemeControlContext.Provider>
</ThemeContext.Provider>
);
};
CSS Variables Strategy for Performance
While React Context handles the theme logic, CSS variables handle the actual styling. This hybrid approach gives you the best performance because changing themes doesn't trigger React re-renders.
// utils/css-variables.ts
import { Theme } from '../tokens';
const flattenTokens = (obj: any, prefix = ''): Record<string, string> => {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}-${key}` : key;
if (typeof value === 'object' && value !== null) {
Object.assign(result, flattenTokens(value, newKey));
} else {
result[newKey] = String(value);
}
}
return result;
};
export const applyThemeVariables = (theme: Theme) => {
const flattened = flattenTokens(theme);
const root = document.documentElement;
Object.entries(flattened).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value);
});
};
export const generateCSSVariables = (theme: Theme): string => {
const flattened = flattenTokens(theme);
return Object.entries(flattened)
.map(([key, value]) => ` --${key}: ${value};`)
.join('\n');
};
// hooks/useThemeVariables.ts
import { useEffect } from 'react';
import { useTheme } from '../context/ThemeContext';
import { applyThemeVariables } from '../utils/css-variables';
export const useThemeVariables = () => {
const { theme } = useTheme();
useEffect(() => {
applyThemeVariables(theme);
}, [theme]);
};
Add this to your root component:
// App.tsx
import { ThemeProvider } from './providers/ThemeProvider';
import { useThemeVariables } from './hooks/useThemeVariables';
const AppContent = () => {
useThemeVariables();
return (
<div className="app">
{/* Your app content */}
</div>
);
};
const App = () => {
return (
<ThemeProvider>
<AppContent />
</ThemeProvider>
);
};
Building Themed Components
Now you can create components that respond to theme changes without prop drilling:
// components/Button.tsx
import { useTheme } from '../context/ThemeContext';
import { clsx } from 'clsx';
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
className?: string;
onClick?: () => void;
}
export const Button = ({
variant = 'primary',
size = 'md',
children,
className,
...props
}: ButtonProps) => {
const { theme } = useTheme();
return (
<button
className={clsx(
'button',
`button--${variant}`,
`button--${size}`,
className
)}
style={{
'--button-primary-bg': theme.colors.primary[500],
'--button-primary-text': theme.colors.text.inverse,
} as React.CSSProperties}
{...props}
>
{children}
</button>
);
};
/* styles/button.css */
.button {
border: none;
border-radius: var(--spacing-sm);
font-family: var(--typography-font-family-sans);
font-weight: var(--typography-font-weight-medium);
cursor: pointer;
transition: all 0.15s ease-in-out;
}
.button--primary {
background-color: var(--button-primary-bg, var(--colors-primary-500));
color: var(--button-primary-text, var(--colors-text-inverse));
}
.button--primary:hover {
background-color: var(--colors-primary-600);
}
.button--secondary {
background-color: var(--colors-background-secondary);
color: var(--colors-text-primary);
border: 1px solid var(--colors-gray-100);
}
.button--ghost {
background-color: transparent;
color: var(--colors-text-primary);
}
.button--sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--typography-font-size-sm);
}
.button--md {
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--typography-font-size-base);
}
.button--lg {
padding: var(--spacing-md) var(--spacing-lg);
font-size: var(--typography-font-size-lg);
}
Theme Switching Component
// components/ThemeToggle.tsx
import { useTheme, useThemeControl } from '../context/ThemeContext';
import { Button } from './Button';
export const ThemeToggle = () => {
const { mode } = useTheme();
const { toggleMode } = useThemeControl();
return (
<Button
variant="ghost"
onClick={toggleMode}
aria-label={`Switch to ${mode === 'light' ? 'dark' : 'light'} mode`}
>
{mode === 'light' ? '🌙' : '☀️'}
</Button>
);
};
Advanced: Multiple Theme Support
For applications that need more than light and dark modes:
// tokens/themes.ts
import { baseTokens } from './base';
import { lightColors, darkColors } from './colors';
const createTheme = (colors: typeof lightColors) => ({
...baseTokens,
colors,
});
export const themes = {
light: createTheme(lightColors),
dark: createTheme(darkColors),
highContrast: createTheme({
...lightColors,
background: {
primary: '#ffffff',
secondary: '#ffffff',
elevated: '#ffffff',
},
text: {
primary: '#000000',
secondary: '#000000',
inverse: '#ffffff',
},
}),
} as const;
export type ThemeMode = keyof typeof themes;
export type Theme = typeof themes.light;
Performance Optimization Tips
Based on my experience with large applications, here are the performance patterns that matter:
// hooks/useThemeValue.ts
import { useMemo } from 'react';
import { useTheme } from '../context/ThemeContext';
export const useThemeValue = <T>(selector: (theme: Theme) => T): T => {
const { theme } = useTheme();
return useMemo(() => selector(theme), [theme, selector]);
};
// Usage in components
const MyComponent = () => {
const primaryColor = useThemeValue(theme => theme.colors.primary[500]);
const spacing = useThemeValue(theme => theme.spacing.md);
return <div style={{ color: primaryColor, padding: spacing }}>Content</div>;
};
// components/ThemedComponent.tsx
import { memo } from 'react';
import { useTheme } from '../context/ThemeContext';
interface ThemedComponentProps {
children: React.ReactNode;
intensive?: boolean;
}
export const ThemedComponent = memo<ThemedComponentProps>(({
children,
intensive
}) => {
const { theme } = useTheme();
return (
<div style={{ backgroundColor: theme.colors.background.primary }}>
{children}
</div>
);
}, (prevProps, nextProps) => {
return prevProps.intensive === nextProps.intensive;
});
ThemedComponent.displayName = 'ThemedComponent';
Testing Your Theme System
// __tests__/theme.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../providers/ThemeProvider';
import { ThemeToggle } from '../components/ThemeToggle';
import { useTheme } from '../context/ThemeContext';
const TestComponent = () => {
const { mode, theme } = useTheme();
return (
<div>
<span data-testid="mode">{mode}</span>
<span data-testid="primary-color">{theme.colors.primary[500]}</span>
</div>
);
};
describe('Theme System', () => {
it('provides default light theme', () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByTestId('mode')).toHaveTextContent('light');
expect(screen.getByTestId('primary-color')).toHaveTextContent('#0ea5e9');
});
it('toggles between light and dark modes', () => {
render(
<ThemeProvider>
<TestComponent />
<ThemeToggle />
</ThemeProvider>
);
const toggle = screen.getByRole('button');
fireEvent.click(toggle);
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
});
it('respects system preference', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: query.includes('dark'),
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
})),
});
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
});
});
Common Pitfalls I've Learned to Avoid
After implementing theme systems across multiple projects, here are the mistakes that will bite you:
Over-optimizing context splitting: Don't split every theme property into its own context. The performance gains aren't worth the complexity unless you have hundreds of themed components.
Forgetting about CSS-in-JS libraries: If you're using styled-components or emotion, you can skip CSS variables and use their native theming:
// With styled-components
import styled, { ThemeProvider as StyledThemeProvider } from 'styled-components';
const StyledButton = styled.button<{ variant: string }>`
background-color: ${({ theme, variant }) =>
variant === 'primary' ? theme.colors.primary[500] : 'transparent'
};
`;
// Wrap your app
<StyledThemeProvider theme={theme}>
<App />
</StyledThemeProvider>
Not testing with reduced motion: Always test your theme transitions with prefers-reduced-motion
:
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
The theme management approach I've shown you scales from small applications to enterprise systems. The key is starting with solid foundations in your design tokens and building up layers of abstraction that each solve specific problems.
I've used variations of this system in applications serving millions of users, and the performance characteristics hold up well. The separation of concerns between React state management and CSS rendering gives you the flexibility to evolve your design system without rewriting your component architecture.
The most important lesson from my experience: invest in the type system early. The TypeScript integration pays dividends when you're adding new themes, refactoring components, or onboarding new team members who need to understand how your theming works.