CSS Variables + Tailwind: The Theming System That Actually Scales
Tailwind gives you bg-blue-500. That's perfect for a prototype. For a product with brand guidelines, white-label requirements, or just dark mode — it falls apart.
blue-500 is always #3b82f6. It doesn't change. It doesn't know about your brand. It doesn't respond to theme switching. And if you scatter it across 200 components, rebranding means grep.
The Problem
Three common approaches and why they fail:
Hardcoded palette — bg-blue-500 everywhere. Rebranding requires find-and-replace across every file. Dark mode needs dark:bg-blue-600 on every element.
Arbitrary values — bg-[#F5A00A] peppered through JSX. Not discoverable, not documented, not themeable. The hex values become tribal knowledge.
Custom Tailwind theme — extending tailwind.config.ts with fixed colors. Better than arbitrary values, but still static. Runtime theme switching is impossible.
The Architecture
The answer is CSS custom properties as the source of truth, mapped into Tailwind utilities.
Step 1: Define tokens as CSS variables
:root {
--brand: 38 92% 50%;
--background: 40 20% 97%;
--foreground: 222 14% 10%;
--card: 0 0% 100%;
--muted: 40 12% 93%;
--border: 40 10% 85%;
}
.dark {
--background: 222 14% 7%;
--foreground: 210 20% 94%;
--card: 222 14% 10%;
--muted: 222 14% 14%;
--border: 222 14% 16%;
}
HSL format (without the hsl() wrapper) because Tailwind can compose alpha values: bg-brand/50 automatically becomes hsl(38 92% 50% / 0.5).
Step 2: Map variables to Tailwind
// tailwind.config.ts
colors: {
brand: {
DEFAULT: "hsl(var(--brand) / <alpha-value>)",
foreground: "hsl(var(--brand-foreground) / <alpha-value>)",
},
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
},
}
Now bg-brand, text-foreground, border-border are all dynamic — their actual values come from CSS variables.
Step 3: Always pair backgrounds with foregrounds
Every surface color needs a companion text color:
<div className="bg-card text-card-foreground">
<div className="bg-brand text-brand-foreground">
<div className="bg-muted text-muted-foreground">
This guarantees contrast. You'll never accidentally put dark text on a dark background because the pairing is built into the system.
Runtime Theme Switching
Because everything is CSS variables, you can switch themes without a page reload:
function applyTheme(tokens: Record<string, string>) {
const root = document.documentElement
for (const [key, value] of Object.entries(tokens)) {
root.style.setProperty(`--${key}`, value)
}
}
Ship theme presets as plain objects:
const themes = {
amber: { brand: "38 92% 50%" },
ocean: { brand: "200 90% 48%" },
rose: { brand: "350 89% 60%" },
}
One line swaps the entire UI's accent color. No rebuild, no class toggling, no CSS-in-JS runtime.
The Rebranding Test
A well-built token system passes this test: can you rebrand the entire product by editing one file?
- Open your tokens file
- Change the brand HSL value
- Every button, link, badge, and accent updates
- Dark mode stays correct
- Charts, status colors, and hover states all derive from the system
If the answer is yes, you have a design system. If you find yourself grepping for hex codes, you have a style sheet.
What to Avoid
// ❌ Raw palette — not themeable
<button className="bg-amber-500 hover:bg-amber-600">
// ❌ Arbitrary hex — not discoverable
<button className="bg-[#F5A00A]">
// ❌ Conditional dark mode — doesn't scale
<button className="bg-white dark:bg-gray-900">
// ✅ Semantic token — works in all themes
<button className="bg-brand text-brand-foreground hover:bg-brand/90">
The upfront cost of a token system is maybe two hours. The ongoing cost of not having one is every time a designer says "can we change the accent color?" and the answer is "that's a two-day refactor."