Introduction
CSS custom properties are live in the browser — you can change them at runtime with JavaScript, scope them to components, and cascade them through the DOM. This makes them perfect for theming.
Whether you're implementing dark mode, brand themes for white-label products, or user-customizable interfaces, CSS variables provide the mechanism.
This guide covers theme variable architecture, switching themes, scoping variables, and integrating with Tailwind and React.
Key Concepts
Defining Theme Variables
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-accent: #3b82f6;
--color-border: #e5e7eb;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--radius-md: 0.5rem;
}
[data-theme="dark"] {
--color-bg-primary: #111827;
--color-bg-secondary: #1f2937;
--color-text-primary: #f9fafb;
--color-text-secondary: #9ca3af;
--color-accent: #60a5fa;
--color-border: #374151;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
}
Using Variables
.card {
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
box-shadow: var(--shadow-sm);
}
Practical Examples
1. Theme Switcher Hook
'use client';
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
export function useTheme() {
const [theme, setTheme] = useState<Theme>('system');
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
return { theme, setTheme };
}
2. Brand Themes for White-Label
[data-brand="brand-a"] {
--color-accent: #e11d48;
--font-sans: 'Poppins', sans-serif;
--radius-md: 1rem;
}
[data-brand="brand-b"] {
--color-accent: #059669;
--font-sans: 'DM Sans', sans-serif;
--radius-md: 0.25rem;
}
3. Tailwind Integration
// tailwind.config.ts
const config = {
theme: {
extend: {
colors: {
bg: { primary: 'var(--color-bg-primary)', secondary: 'var(--color-bg-secondary)' },
text: { primary: 'var(--color-text-primary)' },
accent: { DEFAULT: 'var(--color-accent)' },
},
},
},
};
// Usage
<div className="bg-bg-primary text-text-primary">
<button className="bg-accent text-white rounded px-4 py-2">Themed</button>
</div>
4. Preventing Flash of Wrong Theme
// Inline script in <head> to apply theme before paint
<script dangerouslySetInnerHTML={{ __html: `
(function() {
var t = localStorage.getItem('theme');
if (t && t !== 'system') document.documentElement.setAttribute('data-theme', t);
else if (window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.setAttribute('data-theme', 'dark');
})()
` }} />
Best Practices
- ✅ Define variables on :root, override on [data-theme] selectors
- ✅ Prevent flash of wrong theme with inline script in <head>
- ✅ Support system preference with prefers-color-scheme
- ✅ Use semantic names (bg-primary not white) for theme independence
- ✅ Scope component-specific variables with BEM-like naming
- ❌ Don't mix hardcoded colors with variables — all themed values should be variables
- ❌ Don't nest custom properties too deeply — keep the cascade simple
Common Pitfalls
- Flash of unstyled content — inline script must run before CSS loads
- Forgetting to theme shadows and borders — they look wrong in dark mode
- Using rgba() with variables — CSS variables can't be used inside rgba(), use separate channels
- Not testing all themes — easy to forget hover states and focus rings
Related Guides
- Design Tokens Complete Guide — Token architecture that feeds themes
- Building Component Libraries — Theming in component APIs
- Color Accessibility Beyond Contrast — Ensuring themes are accessible
- Tailwind CSS Guide — Integrating custom properties with utility classes