EmpireUI
Get Pro
← Blog9 min read#theming#css variables#react

Multi-Theme System in React: CSS Variables + Context + localStorage

Build a production-grade multi-theme system in React using CSS custom properties, Context API, and localStorage — with zero re-renders and instant persistence.

Code editor showing CSS custom properties and React theming code

Why CSS Variables Beat Everything Else for Theming

You've probably tried the alternatives. Styled-components theme props, Tailwind's dark: class prefix, a giant themes.js object you pass through props — they all work, but they all have the same problem: switching themes re-renders your entire component tree. CSS custom properties don't. You update one data-theme attribute on <html> and the browser repaints exactly what changed. That's it.

CSS custom properties — added in the CSS Spec Level 4, widely supported since 2017 — are scoped, inheritable, and dynamically updatable without touching JavaScript after the initial paint. A --color-bg: #0f0f0f on :root propagates to every element automatically. Change it at runtime via document.documentElement.style.setProperty() and the whole page updates in a single frame. No diffing. No reconciliation. Zero React involvement.

Honestly, the performance difference is embarrassing once you benchmark it. A theme switch via Context + re-render on a mid-size app takes 40–80ms depending on tree depth. The CSS variable approach: under 5ms. On a 2026 MacBook that's imperceptible, but on a budget Android device it's the difference between smooth and janky.

That said, CSS variables alone don't give you the React-side awareness you often need — things like conditionally rendering a different logo, or knowing which palette to pass to a third-party chart library. That's where Context comes in, and we'll wire those two together without sacrificing the perf win.

Defining Your Theme Tokens

Start with a plain TypeScript object that maps token names to values per theme. Keep it flat — deeply nested theme objects are a maintenance trap that bites you six months later when you're adding a fourth theme at 11pm.

// src/themes/tokens.ts
export type ThemeName = 'light' | 'dark' | 'neon' | 'aurora';

export const themes: Record<ThemeName, Record<string, string>> = {
  light: {
    '--color-bg': '#ffffff',
    '--color-surface': '#f4f4f5',
    '--color-text': '#09090b',
    '--color-text-muted': '#71717a',
    '--color-accent': '#6366f1',
    '--color-border': '#e4e4e7',
    '--radius-base': '8px',
    '--shadow-card': '0 2px 12px rgba(0,0,0,0.08)',
  },
  dark: {
    '--color-bg': '#09090b',
    '--color-surface': '#18181b',
    '--color-text': '#fafafa',
    '--color-text-muted': '#a1a1aa',
    '--color-accent': '#818cf8',
    '--color-border': '#27272a',
    '--radius-base': '8px',
    '--shadow-card': '0 2px 20px rgba(0,0,0,0.4)',
  },
  neon: {
    '--color-bg': '#050505',
    '--color-surface': '#0d0d0d',
    '--color-text': '#e0ffe0',
    '--color-text-muted': '#4ade80',
    '--color-accent': '#00ff88',
    '--color-border': '#00ff8833',
    '--radius-base': '4px',
    '--shadow-card': '0 0 20px #00ff8822, 0 2px 8px rgba(0,0,0,0.6)',
  },
  aurora: {
    '--color-bg': '#0a0a1a',
    '--color-surface': '#12122a',
    '--color-text': '#f0f0ff',
    '--color-text-muted': '#a0a0cc',
    '--color-accent': '#7c3aed',
    '--color-border': '#7c3aed33',
    '--radius-base': '12px',
    '--shadow-card': '0 0 30px #7c3aed22, 0 4px 16px rgba(0,0,0,0.5)',
  },
};

Notice each theme name matches a data-theme attribute value you'll set on <html>. Your CSS then uses the tokens directly — no theme-specific class names, no Tailwind variants, just color: var(--color-text). If you want a fifth theme later, you add one object to this file. Nothing else changes.

Worth noting: the --radius-base and --shadow-card tokens let themes have personality beyond color. The neon theme uses 4px radius for that sharp cyberpunk feel. Aurora goes to 12px for something softer. You can browse how Empire UI's glassmorphism components handle the same idea — backdrop blur values and border opacity are all token-driven.

One more thing — prefix your tokens with a namespace like --empire- or --app- in a real project. It prevents collisions with browser defaults and third-party CSS. I'm skipping it here for readability, but don't skip it in production.

Building the ThemeContext

The Context here is intentionally thin. It holds exactly two things: the current theme name, and a setter. The actual CSS work happens in a side effect, keeping the context payload small and preventing unnecessary re-renders downstream.

// src/themes/ThemeContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { ThemeName, themes } from './tokens';

const STORAGE_KEY = 'app-theme';

function applyTheme(name: ThemeName) {
  const root = document.documentElement;
  const tokens = themes[name];
  // Batch all property sets — one style recalc, not N
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
  root.setAttribute('data-theme', name);
}

type ThemeContextValue = {
  theme: ThemeName;
  setTheme: (name: ThemeName) => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<ThemeName>(() => {
    // Read from localStorage on first render (client only)
    if (typeof window === 'undefined') return 'dark';
    return (localStorage.getItem(STORAGE_KEY) as ThemeName) ?? 'dark';
  });

  useEffect(() => {
    applyTheme(theme);
    localStorage.setItem(STORAGE_KEY, theme);
  }, [theme]);

  function setTheme(name: ThemeName) {
    setThemeState(name);
  }

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
  return ctx;
}

The lazy initializer in useState runs once and reads localStorage synchronously. That's intentional — you want the correct theme value on the very first render, not after a flash. The useEffect then applies the CSS tokens and persists whenever theme changes.

Why not call applyTheme inside setTheme directly? Because then you'd apply it on every render-triggered state update, not just intentional theme changes. The effect dependency array is the right gate. React's rules about effects are occasionally annoying, but they're right here.

Quick aside: the typeof window === 'undefined' guard is there for Next.js SSR. On the server there's no localStorage, so you fall back to your default ('dark' here). You might also want to read a cookie server-side and pass it as an initial prop — but that's a Next.js-specific dance and out of scope for this article.

Preventing the Flash of Unstyled Theme

Here's the thing nobody mentions until you deploy: even with localStorage reads in your lazy initializer, there's a window between the HTML arriving and React hydrating where the user sees the wrong theme. It's a brief white flash if they chose dark mode, or a blinding white page if your default is light. Users hate it.

The fix is a blocking inline script in <head> — before any CSS loads. It reads localStorage and sets data-theme synchronously before the browser paints anything.

<!-- In your Next.js _document.tsx or index.html <head> -->
<script
  dangerouslySetInnerHTML={{
    __html: `
      (function() {
        try {
          var theme = localStorage.getItem('app-theme') || 'dark';
          document.documentElement.setAttribute('data-theme', theme);
        } catch(e) {}
      })();
    `,
  }}
/>

This script runs synchronously, before stylesheets even parse. The try/catch silently handles private browsing mode where localStorage throws. It's ugly — dangerouslySetInnerHTML always is — but it's exactly the right tool. The theme attribute is set before the first paint, and your CSS variables are already scoped to [data-theme] in your stylesheet.

In your global CSS you'd have something like: ``css /* globals.css */ :root { /* Fallback defaults — overridden by JS tokens */ --color-bg: #09090b; --color-text: #fafafa; --color-accent: #818cf8; } body { background-color: var(--color-bg); color: var(--color-text); transition: background-color 200ms ease, color 200ms ease; } ` The transition on body` gives you that smooth crossfade between themes without touching any component code. 200ms is the sweet spot — fast enough to feel snappy, slow enough to be visible. Going above 300ms starts to feel sluggish in 2026.

The Theme Switcher Component

You've got the plumbing. Now you need something a user can actually click. Keep it simple — a <select> or a set of buttons. The component is dumb on purpose; all the logic lives in the context.

// src/components/ThemeSwitcher.tsx
import { useTheme } from '../themes/ThemeContext';
import { ThemeName } from '../themes/tokens';

const THEME_OPTIONS: { value: ThemeName; label: string }[] = [
  { value: 'light', label: 'Light' },
  { value: 'dark', label: 'Dark' },
  { value: 'neon', label: 'Neon' },
  { value: 'aurora', label: 'Aurora' },
];

export function ThemeSwitcher() {
  const { theme, setTheme } = useTheme();

  return (
    <div className="theme-switcher" role="group" aria-label="Choose theme">
      {THEME_OPTIONS.map(({ value, label }) => (
        <button
          key={value}
          onClick={() => setTheme(value)}
          aria-pressed={theme === value}
          data-active={theme === value}
          className="theme-btn"
        >
          {label}
        </button>
      ))}
    </div>
  );
}

The aria-pressed attribute is doing real work there — screen readers announce the active state without any extra spans or hidden text. data-active drives your CSS. Style it however you want; the logic doesn't change.

In practice, you'd style the active button using [data-active='true'] in CSS, which picks up the current --color-accent token automatically. No JavaScript color math. No theme-specific class names in your component. The theme just flows through the tokens and your component stays blissfully unaware.

If you're building something more elaborate — like a full design system where users configure their own palette — you'd extend setTheme to accept a partial token override and merge it with a base theme. But for 90% of apps, four named themes is plenty. Don't over-engineer it before you have the requirement.

Syncing Across Tabs and Respecting System Preference

Two things your users will notice if you skip them: the theme not syncing when they open a new tab, and the app ignoring their OS-level dark mode setting. Neither is hard. Both matter.

For cross-tab sync, add a storage event listener. When localStorage changes in another tab, the current tab fires a storage event with the new value:

// Inside ThemeProvider, add to useEffect:
useEffect(() => {
  function handleStorageChange(e: StorageEvent) {
    if (e.key === STORAGE_KEY && e.newValue) {
      setThemeState(e.newValue as ThemeName);
    }
  }
  window.addEventListener('storage', handleStorageChange);
  return () => window.removeEventListener('storage', handleStorageChange);
}, []);

For system preference, read prefers-color-scheme on first load when there's no saved preference:

function getInitialTheme(): ThemeName {
  if (typeof window === 'undefined') return 'dark';
  const saved = localStorage.getItem(STORAGE_KEY) as ThemeName | null;
  if (saved && saved in themes) return saved;
  // Fall back to OS preference
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

Look, these two additions are maybe 15 lines of code. Skipping them is the kind of thing that gets filed as a bug ticket three weeks after launch. You've already built the hard part — wire these up while the context is fresh in your head.

Worth noting: you can also listen to matchMedia changes dynamically so users who toggle OS dark mode mid-session get updated automatically. That's a MediaQueryList.addEventListener('change', ...) call in a useEffect. Whether that's appropriate depends on your UX — you might not want to override a manual theme selection.

Wiring It Into Your Design System

Once your tokens are flowing through CSS variables, every component gets theming for free. You write background: var(--color-surface) once in a Card component, and it works in all four themes without a single conditional. That's the payoff for the setup work.

If you're building on top of Empire UI, the glassmorphism generator and box shadow generator both output CSS you can replace with variable references. Instead of hardcoding rgba(255,255,255,0.1) as your glass background, define --color-glass-bg per theme and reference it. Now your glassmorphism card adapts to neon green or aurora purple without touching the component.

The design-tokens-guide article goes deeper on the semantic token layer — the difference between --color-indigo-500 (primitive) and --color-accent (semantic). For theming, semantic tokens are what you expose in your context. Primitives live in the theme definition file and never appear in component CSS.

One pattern that scales well: a ThemeStyles component that renders nothing but injects per-theme styles as a <style> tag. Useful for third-party components that only accept className props and won't pick up your CSS variables. You generate a string of CSS from your tokens object and inject it scoped to [data-theme='neon'] .third-party-lib { ... }. Messy but sometimes necessary.

In the end, this whole system — Context for React awareness, CSS variables for zero-render-cost updates, localStorage for persistence, and an inline script for flash prevention — is genuinely production-ready. It's what's running on apps with millions of users. You don't need a theming library for this. You need about 80 lines of TypeScript and the confidence to write them.

FAQ

Can I use this pattern with Tailwind CSS?

Yes — set darkMode: ['attribute', '[data-theme]'] in your tailwind.config.js and Tailwind's dark variants will key off your data-theme attribute. You can mix Tailwind utilities with CSS variable tokens without conflict.

Will this work with Next.js App Router and Server Components?

The ThemeProvider must be a Client Component (add 'use client' at the top). Wrap it in your root layout around the children. Server Components inside it still render on the server; they just can't call useTheme() — pass theme as a prop if a Server Component genuinely needs it.

Why not use Zustand or Redux for the theme state?

Overkill. Theme state is a single string that changes rarely. Context with a tiny payload causes at most one re-render per theme switch, and the CSS variable side-effect means your components don't re-render at all for visual updates. Save Zustand for genuinely complex shared state.

How do I test theme switching in Jest or Vitest?

Mock localStorage with vi.stubGlobal('localStorage', { getItem: vi.fn(), setItem: vi.fn() }) and document.documentElement.setAttribute with a spy. Render your component inside a ThemeProvider and assert the attribute value after calling setTheme. No DOM needed for the core logic.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Custom Properties as a Design System: The Right ArchitectureDark Mode Color Tokens: Building a Theme That Doesn't Break EverythingColor Theme Switcher in React: Multiple Themes, CSS Variables, PersistImplementing Dark Mode in React: CSS Variables, Tailwind, System Preference