Color Theming in React: CSS Variables and Context
Theming is one of those problems that looks simple until you have to maintain it at scale. A single toggle between light and dark mode is straightforward. A product that serves multiple brands, offers user-customizable palettes, or needs to switch themes instantly without a page reload requires a more deliberate architecture.
React's component model and CSS custom properties are a natural pairing for theming. CSS variables handle the color values declaratively; React Context manages the theme state; and Tailwind CSS (in modern projects) bridges both. This guide covers each layer — how to structure theme architecture, implement the CSS variable foundation, connect React state, and handle multi-brand scenarios.
Theme Architecture Patterns
Pattern 1: CSS Variables Only (No JavaScript State)
The simplest pattern for basic dark mode support requires no React state at all. A prefers-color-scheme media query changes custom property values, and every component updates automatically:
/* globals.css */
:root {
--color-bg: #F8FAFC;
--color-text: #1E293B;
--color-brand: #2563EB;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0F172A;
--color-text: #F1F5F9;
--color-brand: #60A5FA;
}
}
Components use var(--color-bg) and never need to know about the current theme. The browser handles everything.
When to use: Sites where you want to respect the OS-level preference and do not need a user-controlled toggle.
Limitation: No way for users to override their OS setting within the app.
Pattern 2: Data Attribute Theme Switching
Add a data-theme attribute on the <html> element to make the active theme explicit and overridable. This is compatible with SSR, avoids flash of wrong theme, and is trivially combined with localStorage persistence:
/* Default: light */
[data-theme="light"],
:root {
--color-bg: #F8FAFC;
--color-text: #1E293B;
--color-brand: #2563EB;
--color-surface: #FFFFFF;
--color-border: #E2E8F0;
}
/* Dark */
[data-theme="dark"] {
--color-bg: #0F172A;
--color-text: #F1F5F9;
--color-brand: #60A5FA;
--color-surface: #1E293B;
--color-border: #334155;
}
// Apply theme before first paint (inline script in <head>)
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
When to use: Most production apps that need a user-controlled toggle. The data attribute approach is the current industry standard — used by Tailwind CSS's dark mode implementation, Radix UI, and most modern design systems.
Pattern 3: Class-Based Theme Switching
Similar to data-attribute, but uses CSS classes. Tailwind's darkMode: 'class' configuration relies on this pattern:
.dark {
--color-bg: #0F172A;
--color-text: #F1F5F9;
}
Adding the dark class to <html> activates the dark token values. Tailwind then applies dark: prefixed utilities when this class is present.
When to use: Projects using Tailwind CSS with the class dark mode strategy.
CSS Variables for Colors: The Token System
Semantic Naming Over Descriptive Naming
The most important decision in a token system is naming. Naming by color value (--blue-500, --gray-900) makes the variable easy to understand in isolation but impossible to theme — changing --blue-500 to purple breaks the name semantics.
Naming by semantic role (--color-brand, --color-text-muted) allows values to change entirely across themes while components remain correct:
:root {
/* ---- Color Primitives (not used directly in components) ---- */
--blue-500: #3B82F6;
--blue-700: #1D4ED8;
--slate-50: #F8FAFC;
--slate-800: #1E293B;
--slate-900: #0F172A;
/* ---- Semantic Tokens (used in components) ---- */
/* Text */
--text-primary: var(--slate-800);
--text-secondary: #64748B;
--text-disabled: #94A3B8;
--text-inverse: var(--slate-50);
/* Surfaces */
--bg-base: var(--slate-50);
--bg-elevated: #FFFFFF;
--bg-sunken: #F1F5F9;
/* Brand */
--brand: var(--blue-700);
--brand-hover: #1E40AF;
--brand-subtle: #EFF6FF;
--on-brand: #FFFFFF;
/* Feedback */
--error: #DC2626;
--error-bg: #FEF2F2;
--success: #16A34A;
--success-bg: #F0FDF4;
--warning: #D97706;
--warning-bg: #FFFBEB;
}
[data-theme="dark"] {
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-disabled: #475569;
--text-inverse: #0F172A;
--bg-base: #0F172A;
--bg-elevated: #1E293B;
--bg-sunken: #0D1426;
--brand: #60A5FA;
--brand-hover: #93C5FD;
--brand-subtle: #172554;
--on-brand: #0F172A;
--error: #F87171;
--error-bg: #1C0A0A;
--success: #4ADE80;
--success-bg: #052E16;
--warning: #FCD34D;
--warning-bg: #1C1000;
}
The two-layer system (primitives + semantic tokens) gives you the best of both worlds: a raw color palette for reference, and semantic tokens for component usage.
Generating Your Color Scale
Before building the token system, you need the raw color palette. The Shade Generator produces a full 50–950 scale from a single brand color — the same scale pattern used by Tailwind CSS. Enter your brand hex code and get the complete set of dark-to-light variants ready to use as primitive tokens.
For example, entering #2563EB as your brand blue generates:
blue-50: #EFF6FFblue-100: #DBEAFEblue-500: #3B82F6blue-700: #1D4ED8blue-900: #1E3A8Ablue-950: #172554
These primitive values then populate your semantic tokens, ensuring your dark mode colors are harmonically related to your light mode colors rather than arbitrary dark approximations.
React Context for Theme State
The ThemeContext Pattern
For apps with a user-controlled theme toggle, React Context provides the state management layer. The context stores the active theme name and exposes a toggle function:
// contexts/ThemeContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark'; // Actual applied theme (resolves 'system')
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
const systemPrefersDark = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const resolvedTheme: 'light' | 'dark' =
theme === 'system'
? (systemPrefersDark ? 'dark' : 'light')
: theme;
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', resolvedTheme);
localStorage.setItem('theme', theme);
}, [theme, resolvedTheme]);
// Listen for system preference changes when theme === 'system'
useEffect(() => {
if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
document.documentElement.setAttribute(
'data-theme',
media.matches ? 'dark' : 'light'
);
};
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}, [theme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
The ThemeProvider wraps your application (or the relevant subtree) and the useTheme hook exposes theme state anywhere in the component tree:
// components/ThemeToggle.tsx
import { useTheme } from '../contexts/ThemeContext';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
style={{
background: 'var(--bg-elevated)',
color: 'var(--text-primary)',
border: '1px solid var(--color-border)',
padding: '8px 16px',
borderRadius: '8px',
cursor: 'pointer',
}}
>
{theme === 'dark' ? '☀ Light' : '☾ Dark'}
</button>
);
}
Components use CSS custom properties for their visual styles — they do not need to read resolvedTheme to apply colors. The context is only needed for UI that displays or controls the current theme state.
Preventing Flash of Wrong Theme (FOTWT)
Server-side rendered apps face the flash of wrong theme problem: the server renders HTML without knowing the user's theme preference, the browser displays that HTML momentarily, then React hydrates and applies the correct theme — causing a visible flash.
The solution is a blocking inline script in <head> that reads localStorage and applies the theme attribute before the page renders:
<!-- In your <head>, before any CSS links -->
<script>
(function() {
try {
var stored = localStorage.getItem('theme');
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored === 'dark' || stored === 'light'
? stored
: (systemDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch(e) {}
})();
</script>
In Next.js, this goes in a _document.tsx or in the App Router's root layout.tsx. Because this script runs synchronously before any CSS parses, there is no flash.
Tailwind CSS Themes
Tailwind v3: Class-Based Dark Mode
In Tailwind v3, dark mode requires setting darkMode: 'class' in tailwind.config.js. This makes Tailwind apply dark: utilities only when the .dark class is on the <html> element:
// tailwind.config.js
module.exports = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#EFF6FF',
100: '#DBEAFE',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
900: '#1E3A8A',
950: '#172554',
},
},
},
},
};
Components use dark: prefixes for dark mode variants:
<div className="bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100">
<button className="bg-brand-600 dark:bg-brand-500 text-white hover:bg-brand-700 dark:hover:bg-brand-400">
Primary Action
</button>
</div>
The theme toggle sets the .dark class on document.documentElement — the same approach as the data-attribute pattern, just using a class instead.
Tailwind v4: CSS-First Configuration
Tailwind v4 moves configuration entirely into CSS, using CSS custom properties natively:
/* styles.css */
@import "tailwindcss";
@theme {
--color-brand-50: #EFF6FF;
--color-brand-100: #DBEAFE;
--color-brand-500: #3B82F6;
--color-brand-600: #2563EB;
--color-brand-700: #1D4ED8;
}
Tailwind v4 generates utility classes from these custom properties, and they are immediately usable in JSX: className="bg-brand-600 text-white". Dark mode configuration in v4 uses @variant dark:
@variant dark (&:where([data-theme="dark"] *)) {
/* Tailwind applies dark: utilities based on this selector */
}
This variant tells Tailwind to apply dark: utilities when an ancestor has data-theme="dark", integrating natively with the data-attribute pattern.
Multi-Brand Theming Strategy
The Challenge
A SaaS platform serving multiple clients, a white-label product, or a design system shared across multiple product brands needs to handle not just dark/light variants but entirely different color identities — each with their own brand primary, accent, status colors, and neutrals.
Brand Tokens as CSS Variable Overrides
The most maintainable approach defines brand-agnostic semantic tokens in a base stylesheet, then overrides primitive assignments per brand:
/* Base theme — same token names for all brands */
:root {
/* Brand Primitives — overridden per brand */
--brand-primary-raw: 37 99 235; /* #2563EB in RGB channels */
--brand-accent-raw: 99 102 241; /* #6366F1 in RGB channels */
/* Semantic tokens — computed from primitives */
--brand-primary: rgb(var(--brand-primary-raw));
--brand-primary-hover: color-mix(in srgb, rgb(var(--brand-primary-raw)) 80%, black);
--brand-primary-subtle: color-mix(in srgb, rgb(var(--brand-primary-raw)) 10%, white);
}
/* Brand: Acme Corp (blue) */
[data-brand="acme"] {
--brand-primary-raw: 37 99 235; /* #2563EB */
--brand-accent-raw: 16 185 129; /* #10B981 */
}
/* Brand: Globex Inc (purple) */
[data-brand="globex"] {
--brand-primary-raw: 124 58 237; /* #7C3AED */
--brand-accent-raw: 245 158 11; /* #F59E0B */
}
/* Brand: Initech (green) */
[data-brand="initech"] {
--brand-primary-raw: 22 163 74; /* #16A34A */
--brand-accent-raw: 239 68 68; /* #EF4444 */
}
Using the raw RGB channel technique (37 99 235) allows opacity variants without additional tokens:
.overlay {
background-color: rgba(var(--brand-primary-raw), 0.1);
}
React Multi-Brand Context
Extend the theme context to handle brand:
// contexts/ThemeContext.tsx
type Brand = 'acme' | 'globex' | 'initech' | 'default';
type Theme = 'light' | 'dark';
interface ThemeContextValue {
theme: Theme;
brand: Brand;
setTheme: (theme: Theme) => void;
setBrand: (brand: Brand) => void;
}
export function ThemeProvider({ initialBrand = 'default', children }: {
initialBrand?: Brand;
children: React.ReactNode;
}) {
const [theme, setTheme] = useState<Theme>('light');
const [brand, setBrand] = useState<Brand>(initialBrand);
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
root.setAttribute('data-brand', brand);
}, [theme, brand]);
return (
<ThemeContext.Provider value={{ theme, brand, setTheme, setBrand }}>
{children}
</ThemeContext.Provider>
);
}
Set the initial brand from your routing or authentication layer — each client's subdomain or tenant ID maps to a brand identifier.
Generating Brand Scales with the Shade Generator
For each brand, you need a complete color scale, not just one or two hex values. Use the Shade Generator to generate a 50–950 scale for each brand's primary and accent color. Enter the brand's primary hex and get the full range of dark-to-light variants.
For Globex Inc with primary #7C3AED, the shade generator produces the full purple scale. For Initech with #16A34A, the full green scale. These scales then populate the primitive tokens for each brand, ensuring that --brand-primary-subtle (the lightest tint) and --brand-primary-hover (a darker pressed state) remain harmonically consistent within each brand identity.
Key Takeaways
- CSS custom properties are the foundation: Define semantic tokens (
--text-primary,--brand-primary) and change their values per theme, rather than conditionally applying classes in every component. - Data-attribute theme switching (
data-theme="dark") is the most flexible pattern — it is overridable by JavaScript, compatible with SSR, and combinable withprefers-color-schememedia queries. - The two-layer token system (primitive color scale + semantic role tokens) allows complete theme flexibility while keeping components clean: primitives define your palette, semantics define how colors are used.
- Use the Shade Generator to generate a complete 50–950 color scale from your brand primary — this gives you the full range of light and dark variants needed for a thorough token system.
- React Context manages theme state (the active theme name) and side effects (setting the data attribute, persisting to localStorage); components never need to read context to apply the correct colors.
- Prevent flash of wrong theme with a blocking inline script in
<head>that readslocalStorageand applies the theme attribute before the browser renders any pixels. - Tailwind CSS integration: v3 uses
darkMode: 'class'withdark:prefixes; v4 uses CSS-first@themeconfiguration with native CSS variable support. - Multi-brand theming stacks another data attribute (
data-brand) on top of the theme system, overriding primitive token values per brand while keeping semantic tokens and components unchanged.