Dynamic Color Theming in React: Beyond CSS Variables
Most theming tutorials stop at the dark mode toggle. Switch a class on <html>, flip a handful of CSS variables, done. That covers a common case but misses the more interesting problem: what if users can choose their own brand color? What if your SaaS product serves multiple clients, each with their own identity? What if the theme needs to generate an entire harmonious palette from a single input hex value at runtime?
That is where dynamic color theming begins — and CSS variables alone are not sufficient. You need runtime color algorithms, React state that survives navigation, persistence strategies, and an architecture that does not re-render your entire component tree every time a slider moves.
This guide covers each of those layers in depth.
CSS Variable Approach for React Themes
The Baseline: Semantic Token Architecture
Every dynamic theme system starts with the same foundation: CSS custom properties named by their semantic role, not their color value. Do not name a variable --blue-500. Name it --brand-primary. The value can change; the role cannot:
/* globals.css */
:root {
/* Semantic tokens — these are what components use */
--bg-base: #F8FAFC;
--bg-surface: #FFFFFF;
--bg-sunken: #F1F5F9;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-disabled: #94A3B8;
--brand-primary: #2563EB;
--brand-hover: #1D4ED8;
--brand-subtle: #EFF6FF;
--on-brand: #FFFFFF;
--border-default: #E2E8F0;
--border-strong: #CBD5E1;
--status-error: #DC2626;
--status-success: #16A34A;
--status-warning: #D97706;
}
Components reference tokens, never raw colors:
.card {
background: var(--bg-surface);
border: 1px solid var(--border-default);
color: var(--text-primary);
}
.btn-primary {
background: var(--brand-primary);
color: var(--on-brand);
}
.btn-primary:hover {
background: var(--brand-hover);
}
When you update --brand-primary in JavaScript, every component that references it updates instantly — without React re-rendering, without prop drilling, without context subscriptions. CSS handles the cascade.
Writing Tokens from JavaScript
The DOM API for setting custom properties is straightforward:
document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');
Reading them back:
const brand = getComputedStyle(document.documentElement)
.getPropertyValue('--brand-primary')
.trim();
This is the mechanical foundation. The hard part is generating the full set of tokens from a single brand hex.
Runtime Theme Generation with Color Algorithms
The Problem with Manual Token Sets
If users can pick any color — including unusual ones like #FF5733 or #0D9488 — you cannot hardcode a dark variant, a hover state, or an on-brand text color. You need to compute them from the input.
Generating the Full Token Set
Here is a complete function that takes a brand hex and produces all the tokens your theme needs:
import chroma from 'chroma-js';
function generateThemeTokens(brandHex) {
const brand = chroma(brandHex);
// Determine if on-brand text should be black or white
// Based on WCAG relative luminance
const brandLuminance = brand.luminance();
const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';
// Generate a scale from light tint to dark shade in OKLCH
// for perceptually uniform steps
const scale = chroma.scale([
chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
brandHex,
chroma(brandHex).darken(2.5).hex(),
]).mode('oklch').colors(11);
return {
'--brand-primary': brandHex,
'--brand-hover': chroma(brandHex).darken(0.5).hex(),
'--brand-active': chroma(brandHex).darken(1).hex(),
'--brand-subtle': scale[1], // Very light tint
'--brand-muted': scale[2], // Light tint
'--on-brand': onBrand,
// Scale primitives for complete palette access
'--brand-50': scale[0],
'--brand-100': scale[1],
'--brand-200': scale[2],
'--brand-300': scale[3],
'--brand-400': scale[4],
'--brand-500': scale[5],
'--brand-600': scale[6],
'--brand-700': scale[7],
'--brand-800': scale[8],
'--brand-900': scale[9],
'--brand-950': scale[10],
};
}
function applyTheme(brandHex) {
const tokens = generateThemeTokens(brandHex);
const root = document.documentElement;
Object.entries(tokens).forEach(([prop, value]) => {
root.style.setProperty(prop, value);
});
}
Accessible Color Pairs
When generating themes from arbitrary user input, accessibility cannot be assumed. A user-chosen color might produce poor contrast against white. You need to check and adjust:
function ensureContrast(foreground, background, minRatio = 4.5) {
let color = chroma(foreground);
let ratio = chroma.contrast(color.hex(), background);
// If contrast is insufficient, darken the foreground until it passes
let iterations = 0;
while (ratio < minRatio && iterations < 20) {
color = color.darken(0.2);
ratio = chroma.contrast(color.hex(), background);
iterations++;
}
return color.hex();
}
// For a brand-colored text label on white background
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // Yellow adjusted to pass AA
Apply this when generating tokens for text elements that appear on white or other fixed surfaces.
Complementary and Accent Colors
The Palette Generator shows how a single base color produces a full harmony family. You can replicate that logic in code using hue rotation:
function generateComplementaryAccent(brandHex) {
const hsl = chroma(brandHex).hsl();
// Rotate hue 180° for complement, shift lightness to maintain balance
const complementHue = (hsl[0] + 180) % 360;
return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}
function generateAnalogousAccent(brandHex, degrees = 30) {
const hsl = chroma(brandHex).hsl();
const analogousHue = (hsl[0] + degrees) % 360;
return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}
React Context for Theme State Management
ThemeContext Architecture
The theme context serves as the bridge between user interactions (picking a color, toggling dark mode) and the DOM-level token application. It should be thin: it stores state, applies it to the DOM, and exposes a setter. Color computation happens outside the context:
// contexts/ThemeContext.tsx
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';
interface ThemeState {
brandHex: string;
mode: 'light' | 'dark' | 'system';
}
interface ThemeContextValue extends ThemeState {
setBrand: (hex: string) => void;
setMode: (mode: 'light' | 'dark' | 'system') => void;
resolvedMode: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const STORAGE_KEY = 'app-theme';
function getStoredTheme(): ThemeState {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored) as ThemeState;
} catch {}
return { brandHex: '#2563EB', mode: 'system' };
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ThemeState>(getStoredTheme);
const systemDark =
typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const resolvedMode: 'light' | 'dark' =
state.mode === 'system'
? systemDark ? 'dark' : 'light'
: state.mode;
// Apply CSS tokens whenever state changes
useEffect(() => {
const tokens = generateThemeTokens(state.brandHex);
const root = document.documentElement;
Object.entries(tokens).forEach(([prop, value]) => {
root.style.setProperty(prop, value as string);
});
root.setAttribute('data-theme', resolvedMode);
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state, resolvedMode]);
// React to system preference changes
useEffect(() => {
if (state.mode !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
document.documentElement.setAttribute(
'data-theme',
e.matches ? 'dark' : 'light',
);
};
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}, [state.mode]);
const setBrand = useCallback((hex: string) => {
setState(prev => ({ ...prev, brandHex: hex }));
}, []);
const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
setState(prev => ({ ...prev, mode }));
}, []);
return (
<ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
{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 Color Picker Component
With the context in place, a color picker becomes a thin UI layer:
// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';
export function BrandColorPicker() {
const { brandHex, setBrand } = useTheme();
const [localHex, setLocalHex] = useState(brandHex);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setLocalHex(value);
// Debounce the expensive token generation
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
setBrand(value);
}
}, 150);
};
return (
<div className="flex items-center gap-3">
<label htmlFor="brand-picker" className="text-sm font-medium">
Brand Color
</label>
<div className="relative">
<input
id="brand-picker"
type="color"
value={localHex}
onChange={handleChange}
className="sr-only"
/>
<label
htmlFor="brand-picker"
className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
style={{
backgroundColor: localHex,
// ring color uses the brand token
'--tw-ring-color': 'var(--brand-primary)',
} as React.CSSProperties}
aria-label={`Current brand color: ${localHex}`}
/>
</div>
<input
type="text"
value={localHex}
onChange={handleChange}
className="w-24 px-2 py-1 text-sm font-mono border rounded"
placeholder="#2563EB"
aria-label="Brand color hex value"
/>
</div>
);
}
The 150ms debounce is critical — without it, generateThemeTokens runs on every keystroke when typing in the hex input, which is unnecessarily expensive.
Tailwind CSS Dynamic Color Classes
The Challenge with Tailwind and Runtime Colors
Tailwind generates utility classes at build time. Classes like bg-brand-500 only exist in your stylesheet if Tailwind's scanner found them in your source files. A runtime-determined color — say, whatever the user just picked — will not be in the stylesheet.
There are two solutions, and they serve different needs:
Solution 1: CSS Variable Bridge
The cleanest approach is to define a small set of Tailwind theme tokens that point to CSS custom properties, then control the values of those properties at runtime:
/* styles.css (Tailwind v4) */
@import "tailwindcss";
@theme {
--color-brand: var(--brand-primary);
--color-brand-hover: var(--brand-hover);
--color-brand-subtle: var(--brand-subtle);
--color-on-brand: var(--on-brand);
}
Now bg-brand, text-brand, and hover:bg-brand-hover are valid Tailwind classes that reflect whatever value --brand-primary holds at runtime:
<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
Primary Action
</button>
Changing --brand-primary via JavaScript instantly updates every element with bg-brand — no re-render needed.
Solution 2: Inline Styles for One-Off Dynamic Colors
When a component needs a truly unique runtime color (such as a color swatch in a palette viewer), use an inline style. Tailwind's bg-[#FF5733] arbitrary value syntax works for static values known at build time, but not for bg-[${dynamicHex}] inside a template literal in JSX — the class won't exist in the stylesheet.
// Correct for truly dynamic colors:
<div
style={{ backgroundColor: swatch.hex }}
className="w-8 h-8 rounded"
aria-label={swatch.name}
/>
// This will NOT work at runtime (class not in stylesheet):
<div className={`bg-[${swatch.hex}]`} /> // Wrong
Persisting User Color Preferences
localStorage for Persistence Across Sessions
The ThemeContext above already writes to localStorage in the useEffect. The key pattern for SSR compatibility is initializing state lazily — passing a function to useState that reads localStorage only on the client, never during server rendering:
const [state, setState] = useState<ThemeState>(() => {
// This function only runs on the client, after hydration
if (typeof window === 'undefined') {
return { brandHex: '#2563EB', mode: 'system' };
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
} catch {
return { brandHex: '#2563EB', mode: 'system' };
}
});
Preventing Flash of Default Theme on Load
For server-rendered apps (Next.js App Router), the component tree renders on the server before it reaches the browser. If you initialize with the default theme, users who have saved a custom theme will see a flash — the default blue appears momentarily before the persisted purple loads.
The solution is a blocking inline script in the <head> that applies the saved theme before the browser renders any pixels:
// 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 = JSON.parse(localStorage.getItem('app-theme') || '{}');
var brand = stored.brandHex || '#2563EB';
var mode = stored.mode || 'system';
var resolvedMode = mode === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: mode;
document.documentElement.setAttribute('data-theme', resolvedMode);
// Minimal inline tokens to prevent flash
document.documentElement.style.setProperty('--brand-primary', brand);
} catch(e) {}
})();
`,
}}
/>
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
The inline script applies only the most critical tokens (brand color, theme mode attribute) synchronously. The full token set is applied by the ThemeProvider after hydration — but since the dominant visual tokens are already correct, users see no flash.
Server-Side Persistence via Cookies
For authenticated apps, storing the theme in a cookie (readable by the server) enables SSR with the correct theme from the first byte:
// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
const mode = request.cookies.get('theme-mode')?.value ?? 'system';
// Pass theme to the page via response headers or search params
const response = NextResponse.next();
response.headers.set('x-brand-hex', brandHex);
response.headers.set('x-theme-mode', mode);
return response;
}
Read the headers in your root layout.tsx server component and pass the initial theme to the ThemeProvider as a prop. The provider initializes with the server-known theme, eliminating the flash entirely.
Generating Accessible Dark Mode Variants
When you generate a light theme from a brand color, you also need a dark mode version. The challenge: the same brand hex that works well on a light background — say, #2563EB — often fails WCAG contrast requirements on dark surfaces.
A robust approach generates two token sets — one for light, one for dark — from the same brand input:
function generateDarkTokens(brandHex) {
const brand = chroma(brandHex);
const hsl = brand.hsl();
// For dark mode, lighten the brand to ensure contrast against dark surfaces
const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));
return {
'[data-theme="dark"]': {
'--brand-primary': darkBrand.hex(),
'--brand-hover': darkBrand.lighten(0.3).hex(),
'--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
'--on-brand': '#000000', // Often black on lightened brand in dark mode
'--bg-base': '#0F172A',
'--bg-surface': '#1E293B',
'--text-primary': '#F1F5F9',
'--text-secondary': '#94A3B8',
},
};
}
Apply dark tokens via the data-theme="dark" attribute pattern. When the ThemeProvider sets document.documentElement.setAttribute('data-theme', 'dark'), the CSS cascade applies the dark-mode token values.
For verifying that a generated color pair passes WCAG, use the Palette Generator to visualize the full scale and check contrast ratios between steps.
Key Takeaways
- CSS custom properties are the foundation — semantic tokens named by role, not color value, allow complete theme changes without modifying components.
- Runtime token generation from a single brand hex requires: a lightness/darkness scale, WCAG contrast checking for on-brand text, and separate computation for dark mode variants.
- React context manages theme state (active brand hex, light/dark mode) and applies tokens to the DOM as a side effect — components never need to subscribe to theme context to apply colors.
- Debounce expensive color calculations (150ms) when connected to continuous inputs like color pickers or sliders.
- For Tailwind CSS: define a CSS variable bridge in
@themesobg-brandutilities reflect the runtime token value; use inline styles only for truly unique per-element dynamic colors. - Prevent flash of default theme with a synchronous blocking script in
<head>that applies the most critical tokens before render. - For SSR apps with per-user themes, store the theme in a cookie so the server can render the correct initial HTML.
- Use the Shade Generator to inspect and validate generated color scales against the full 50–950 range, and the Palette Generator to verify harmony and contrast ratios across the full theme palette.