The v3 problem with CSS variables
In Tailwind v3, using CSS variables for theming required a specific pattern. You stored raw color channels in a variable, referenced them in the JavaScript config with an hsl() wrapper, and hoped everything aligned:
/* globals.css */
:root {
--primary: 239 84% 67%;
}
/* tailwind.config.js */
colors: {
primary: 'hsl(var(--primary))',
}This worked but had friction. The variable had to contain raw channels (not a complete color). Opacity modifiers (bg-primary/50) required specific formatting. And the split between CSS file and JS config meant two files to update for every color change.
The v4 solution
Tailwind v4 collapses this into one file. Your CSS variables, theme registration, and base styles all live together:
@import "tailwindcss";
/* Define the dynamic values */
:root {
--primary: 239 84% 67%;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
}
.dark {
--primary: 239 90% 71%;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
}
/* Register as Tailwind tokens */
@theme {
--color-primary: hsl(var(--primary));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
}One file. No JavaScript config. The :root and .dark blocks hold the dynamic values. The @theme block registers them with Tailwind. Clean.
Dynamic themes at runtime
Because the actual color values live in CSS variables (not compiled into the build), you can change them at runtime. This opens up use cases that were painful or impossible in v3:
User-customizable themes
Let users pick their own colors. Update the CSS variables via JavaScript and the entire UI repaints instantly:
function applyUserTheme(primaryColor: string) {
/* Convert hex to HSL channels */
const hsl = hexToHSL(primaryColor);
document.documentElement.style.setProperty('--primary', hsl);
}
/* User picks a color, UI updates in real time */
<input type="color" onChange={(e) => applyUserTheme(e.target.value)} />Every element using bg-primary, text-primary, border-primary, or ring-primary updates instantly. No re-render. No state management. Just CSS doing what CSS does.
Multi-tenant color systems
Building a SaaS where each customer has their own brand colors? Set the variables based on the tenant:
/* Set tenant theme on the server or in a layout component */
const tenantThemes = {
acme: { primary: '239 84% 67%', accent: '262 83% 58%' },
globex: { primary: '142 76% 36%', accent: '173 58% 39%' },
initech: { primary: '0 72% 51%', accent: '43 74% 66%' },
};
function TenantLayout({ tenantId, children }) {
const theme = tenantThemes[tenantId];
return (
<div
style={{
'--primary': theme.primary,
'--accent': theme.accent,
} as React.CSSProperties}
>
{children}
</div>
);
}Same components, different colors per tenant. No conditional class logic. No theme provider libraries. Just scoped CSS variables.
Theme previews
Show users a live preview of different themes. Scope variables to a container and everything inside adopts the preview theme:
/* Preview a theme in a specific section */
<div
className="rounded-lg border p-6"
style={{
'--primary': '262 83% 58%',
'--background': '270 20% 98%',
'--foreground': '262 30% 10%',
} as React.CSSProperties}
>
<Button>Preview Button</Button>
<Card>Preview Card</Card>
</div>The token layer architecture
The cleanest pattern for complex apps uses three layers:
/* Layer 1: Primitive tokens (your raw palette) */
:root {
--indigo-500: 239 84% 67%;
--indigo-600: 239 84% 57%;
--slate-50: 210 40% 98%;
--slate-900: 222 47% 11%;
}
/* Layer 2: Semantic tokens (role-based aliases) */
:root {
--primary: var(--indigo-500);
--primary-hover: var(--indigo-600);
--background: var(--slate-50);
--foreground: var(--slate-900);
}
.dark {
--primary: var(--indigo-400);
--background: var(--slate-900);
--foreground: var(--slate-50);
}
/* Layer 3: Tailwind registration */
@theme {
--color-primary: hsl(var(--primary));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
}Primitives are your raw color values. Semantic tokens map roles to primitives. The @theme block registers semantic tokens with Tailwind. To change the entire brand, swap the semantic layer. To support a new theme, add a new selector that reassigns the semantic variables.
Performance: it's just CSS
CSS variable updates are handled by the browser's rendering engine, not JavaScript. Changing a variable triggers a repaint of affected elements, not a React re-render. This makes theme switching instantaneous even on complex pages with hundreds of themed elements.
Compare this to JavaScript-based theming solutions that store colors in state or context. Every color change triggers a re-render cascade. With CSS variables, React doesn't even know the theme changed. The browser handles it natively.
Combining with design seeds
This architecture is exactly what SeedFlip exports. Each design seed is a complete set of CSS variables (colors, fonts, spacing, shadows, border-radius) in both light and dark modes. Paste a seed's export into your CSS file, and your entire app adopts a new design system. The @theme registration stays the same. Only the variable values change.
/* Swap design systems by changing variable values */
/* Seed: "Glacier" (Vercel-inspired) */
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
}
/* Seed: "Amethyst" (Stripe-inspired) */
:root {
--background: 260 20% 98%;
--foreground: 262 30% 10%;
--primary: 262 83% 58%;
--primary-foreground: 0 0% 100%;
--border: 260 10% 90%;
}Same components. Same @theme block. Completely different visual identity. This is the power of CSS variables as a first-class theming layer.
Tailwind v4's CSS-first approach makes theming dramatically simpler. No JavaScript config. No build-time color resolution. Just CSS variables that cascade, scope, and update at runtime. For the foundational color system, read Tailwind v4 Color System. For the CSS custom properties primer, see CSS Custom Properties Explained. And for a deeper look at variable-based color architecture, read CSS Variables Color System.