Color Theme Switcher in React: Multiple Themes, CSS Variables, Persist
Build a multi-theme color switcher in React using CSS variables and localStorage — light, dark, and custom themes that actually persist across page reloads.
Why CSS Variables Are the Right Tool for This
There are roughly three ways to do multi-theme switching in React: swap CSS classes on a root element, write theme objects in JavaScript and inject them as inline styles, or use CSS custom properties (variables) with a data-attribute on <html>. The third approach wins, and it's not even close.
Honestly, the JS-in-JS approach sounds clean until you realize you're re-rendering every styled component in your tree every time someone clicks a theme button. CSS variables live in the browser's style engine. Swap a data-theme attribute on the root element, the cascade propagates instantly across every element that references those tokens — zero React re-renders required.
Worth noting: CSS custom properties are scoped. You can define :root { --color-bg: #fff; } globally and then [data-theme='dark'] { --color-bg: #0f0f0f; } to override only what changes. That means your component CSS references var(--color-bg) and never thinks about which theme is active. It just works. This approach scales to five themes or twenty without touching a single component file.
CSS custom properties are supported in every browser since 2017 — we're well past worrying about IE11. If you're targeting a modern stack (Next.js 14+, Vite, Remix), you can use this pattern unconditionally.
Defining Your Theme Tokens
Start in your global CSS file. Define sensible defaults on :root, then override them per theme. The trick is to keep your token names *semantic*, not descriptive — --color-surface instead of --color-white, --color-text-primary instead of --color-black. This way swapping a dark theme doesn't require renaming anything.
/* globals.css */
:root {
--color-bg: #ffffff;
--color-surface: #f4f4f5;
--color-border: #e4e4e7;
--color-text-primary: #09090b;
--color-text-muted: #71717a;
--color-accent: #7c3aed;
--color-accent-hover: #6d28d9;
--radius-card: 12px;
--shadow-card: 0 4px 24px rgba(0,0,0,0.06);
}
[data-theme='dark'] {
--color-bg: #09090b;
--color-surface: #18181b;
--color-border: #27272a;
--color-text-primary: #fafafa;
--color-text-muted: #a1a1aa;
--color-accent: #a78bfa;
--color-accent-hover: #c4b5fd;
--shadow-card: 0 4px 24px rgba(0,0,0,0.4);
}
[data-theme='ocean'] {
--color-bg: #0c1a2e;
--color-surface: #112240;
--color-border: #1d3461;
--color-text-primary: #ccd6f6;
--color-text-muted: #8892b0;
--color-accent: #64ffda;
--color-accent-hover: #0ff;
--shadow-card: 0 4px 24px rgba(0,0,0,0.5);
}
[data-theme='rose'] {
--color-bg: #fff1f2;
--color-surface: #ffe4e6;
--color-border: #fecdd3;
--color-text-primary: #1c0a0d;
--color-text-muted: #9f1239;
--color-accent: #e11d48;
--color-accent-hover: #be123c;
--shadow-card: 0 4px 24px rgba(225,29,72,0.12);
}Four themes, and every future component just reads from these variables. Quick aside: that 12px --radius-card token means if a designer decides everything should be squarer, you change one line. Token-based design systems earn their complexity back fast — usually around the time you hit your third theme or your second designer.
If you're pulling in components from Empire UI, you'll notice the component library already ships its own CSS variable layer. The patterns here compose naturally with that — you're just adding your own semantic tokens on top.
The useTheme Hook with localStorage Persistence
Now the React side. You want a hook that reads the user's last choice from localStorage, applies it immediately (to avoid a flash of wrong theme), and exposes a setTheme function any component can call. Here's the full implementation:
// hooks/useTheme.ts
import { useEffect, useState } from 'react';
export type Theme = 'light' | 'dark' | 'ocean' | 'rose';
const STORAGE_KEY = 'empire-ui-theme';
const DEFAULT_THEME: Theme = 'light';
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return DEFAULT_THEME;
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored) return stored;
// Respect OS preference as the fallback
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: DEFAULT_THEME;
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
function setTheme(next: Theme) {
setThemeState(next);
}
return { theme, setTheme };
}A few things to notice. The getInitialTheme function runs synchronously inside useState's initializer — that's intentional. By the time React renders, the state already reflects what's in storage. It won't help with SSR flicker (more on that in a moment), but for pure client-side apps it eliminates the one-tick delay.
The useEffect watches theme and applies both side effects together: DOM attribute + storage. They're coupled so they can't drift apart. That said, you might want to debounce the storage write if you're building a theme preview where users can click through options rapidly — 10 writes in 200ms isn't a problem technically, but it's unnecessary.
In practice, the OS preference fallback is the detail most tutorials skip. Users who never explicitly picked a theme still get a sensible default. Don't ignore that — it matters more than you'd think for first impressions.
Building the ThemeSwitcher Component
The hook is headless. Now build the UI. Here's a button-group switcher that you can drop into a navbar or settings panel:
// components/ThemeSwitcher.tsx
import { useTheme, Theme } from '../hooks/useTheme';
const THEMES: { id: Theme; label: string; swatch: string }[] = [
{ id: 'light', label: 'Light', swatch: '#ffffff' },
{ id: 'dark', label: 'Dark', swatch: '#09090b' },
{ id: 'ocean', label: 'Ocean', swatch: '#64ffda' },
{ id: 'rose', label: 'Rose', swatch: '#e11d48' },
];
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div
role="radiogroup"
aria-label="Color theme"
style={{
display: 'flex',
gap: '8px',
padding: '6px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: '10px',
}}
>
{THEMES.map(({ id, label, swatch }) => (
<button
key={id}
role="radio"
aria-checked={theme === id}
aria-label={label}
onClick={() => setTheme(id)}
title={label}
style={{
width: 28,
height: 28,
borderRadius: '50%',
border: theme === id
? '3px solid var(--color-accent)'
: '2px solid var(--color-border)',
background: swatch,
cursor: 'pointer',
outline: 'none',
transition: 'border 0.15s ease',
}}
/>
))}
</div>
);
}The 28px swatch buttons are small but tappable — Apple's minimum touch target is 44px, so if you're targeting mobile you'll want to wrap each in a larger clickable area. The role="radiogroup" + role="radio" + aria-checked pattern gives screen readers exactly the right semantics: this is a group of mutually exclusive choices.
Look, inline styles here are deliberate. The switcher itself reads from the CSS variables it's also responsible for changing — using a pre-built class system for this specific component adds indirection without benefit. Keep it simple.
Plug useTheme directly where you need it — no context required for most apps. But if you're calling useTheme in many components simultaneously, wrap it in a context so you're not duplicating the localStorage.getItem call on every mount. Here's the quick context version:
// context/ThemeContext.tsx
import { createContext, useContext, ReactNode } from 'react';
import { useTheme, Theme } from '../hooks/useTheme';
type ThemeCtx = { theme: Theme; setTheme: (t: Theme) => void };
const ThemeContext = createContext<ThemeCtx | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useTheme();
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useThemeContext() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useThemeContext must be inside ThemeProvider');
return ctx;
}Fixing the Flash of Wrong Theme on SSR (Next.js)
If you're on Next.js (App Router or Pages Router), you'll hit the classic hydration mismatch: the server renders with the default theme, the client reads localStorage and gets something different, and for a split second users see the wrong theme. On fast connections it's a barely-visible flash. On 3G it's jarring.
The fix is a blocking inline script in <head> — not a React component, an actual <script> tag that runs before any painting happens. In Next.js App Router, put this in your root layout.tsx:
// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var stored = localStorage.getItem('empire-ui-theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch(e) {}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}The suppressHydrationWarning on <html> tells React not to yell about the data-theme attribute mismatch between server and client. Without it, you'll see a hydration error in development — harmless but noisy. The try/catch wrapper handles environments where localStorage is blocked (private browsing in some browsers, iframe sandboxes).
One more thing — this script pattern is intentionally not a React Server Component. It has to be raw HTML that executes synchronously before the browser starts painting. That's the only way to guarantee zero flash. It's a rare case where old-school <script> beats modern React patterns.
Adding Smooth Transitions Between Themes
A hard-cut theme swap feels abrupt. Two lines of CSS fix it — add a transition on the properties that will change:
/* globals.css — add after your token definitions */
*,
*::before,
*::after {
transition:
background-color 200ms ease,
border-color 200ms ease,
color 150ms ease,
box-shadow 200ms ease;
}
/* Opt-out for elements where transitions look wrong */
.no-transition,
.no-transition * {
transition: none !important;
}The 200ms duration hits a sweet spot — fast enough that it doesn't feel sluggish, slow enough that users register the intentional change. Don't transition all properties. That catches things like width and transform which will then animate awkwardly when they change for unrelated reasons.
Respect prefers-reduced-motion. Wrap those transitions in a media query or they'll animate for users who've explicitly asked their OS to minimize motion:
@media (prefers-reduced-motion: no-preference) {
*,
*::before,
*::after {
transition:
background-color 200ms ease,
border-color 200ms ease,
color 150ms ease,
box-shadow 200ms ease;
}
}If you're building something visually ambitious — say, integrating these themes with glassmorphism components or aurora-style backgrounds — you'll want to also transition --color-accent consumers carefully. SVG fills and strokes don't respond to CSS variable transitions the same way box-model properties do. Test your icons.
Connecting Themes to Design System Components
The real payoff from a token-based system is that every component you build automatically participates in theming. No props, no context reads, no conditional class names — just var(--color-surface) in the component CSS and you're done. Here's a card that works across all four themes without modification:
// components/Card.tsx
export function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-card)',
padding: '24px',
}}>
<h3 style={{
color: 'var(--color-text-primary)',
marginBottom: '8px',
fontSize: '1.125rem',
fontWeight: 600,
}}>{title}</h3>
<p style={{ color: 'var(--color-text-muted)', lineHeight: 1.6 }}>{children}</p>
</div>
);
}That's it. Drop it on any page, switch themes — the card follows without any changes. This is why the token-naming discipline upfront matters. If you'd named the token --white-surface instead of --color-surface, your dark theme override would be fighting you semantically.
For interactive components — buttons with hover states, focused inputs — reach for the --color-accent and --color-accent-hover pair. Keep hover states predictable: slightly darker in light themes, slightly lighter in dark ones. Users have strong muscle memory about what hover should look like and it breaks trust when it's inverted unexpectedly.
If you want to see this entire pattern in action inside a fully-built component library, browse components on Empire UI. The gradient generator and box shadow generator tools are also worth checking out — they output CSS values that drop directly into your token file.
FAQ
Yes. Configure Tailwind to use a data-theme selector instead of class by setting darkMode: ['attribute', 'data-theme'] in tailwind.config.js. Your CSS variable tokens and Tailwind dark variants then both respond to the same attribute. You can mix them freely in the same component.
Listen to the storage event on window. When another tab writes to localStorage, the event fires in all other tabs. Add window.addEventListener('storage', handler) inside your useTheme hook's useEffect and call setThemeState with the new value when the key matches your storage key.
Absolutely. Instead of storing a theme name string, store a JSON object of overrides and apply them as inline styles on document.documentElement via Object.entries(tokens).forEach(([k, v]) => root.style.setProperty(k, v)). This lets you ship a color-picker UI that generates arbitrary themes without touching your CSS.
next-themes is solid and does handle SSR flicker, system preference detection, and tab sync for you. Rolling it yourself makes sense when you need more than light/dark — say, four named themes with different radius and shadow tokens — or when you want full control without a dependency. Both approaches are valid; pick based on your complexity.