EmpireUI
Get Pro
← Blog7 min read#tailwind-css#css-variables#theming

Tailwind + CSS Variables: Dynamic Theming Without JavaScript

Stop reaching for JavaScript to swap themes. Tailwind CSS variables let you build dynamic, multi-theme UIs with pure CSS — faster, cleaner, zero runtime cost.

Code editor showing CSS custom properties and Tailwind configuration on a dark background

Why CSS Variables Beat JavaScript Theming

Honestly, most theme-switching code you'll find in the wild is 30 lines of JavaScript doing something CSS has handled natively for years. We're talking document.body.setAttribute('data-theme', 'dark'), a React context, a useState hook, maybe a localStorage sync — all of it just to change some colors.

CSS custom properties (what most people call CSS variables) are inherited, cascade-aware, and can be swapped in a single attribute change with zero JavaScript runtime cost. The browser just re-paints. That's it.

Tailwind v4.0.2 leans into this heavily. The new CSS-first configuration model means your design tokens live in CSS, not in a JavaScript config object. Once you internalize that shift, a lot of your old theming patterns start looking unnecessarily complicated.

How Tailwind Generates CSS Variables from Your Config

In Tailwind v4, when you define a color or spacing scale, the framework generates corresponding CSS custom properties on :root. Your --color-primary-500 token doesn't just live in JavaScript — it becomes a real variable the browser can reference at runtime.

This matters because you can now override those variables in a scoped selector. Wrap a component in .theme-dark and redefine --color-surface to rgba(15, 15, 20, 0.92). Every Tailwind utility that references surface inside that wrapper picks up the new value automatically.

Here's what that looks like in practice. You define the token once, override it per-theme, and Tailwind utilities stay unchanged: ``css /* globals.css */ :root { --color-surface: #ffffff; --color-surface-elevated: #f4f4f5; --color-text-primary: #09090b; --color-accent: #6366f1; --spacing-card: 24px; } [data-theme="dark"] { --color-surface: #09090b; --color-surface-elevated: #18181b; --color-text-primary: #fafafa; --color-accent: #818cf8; } ` No JavaScript required to define these. The browser switches them the moment you flip the data-theme` attribute.

Wiring Custom Properties into Tailwind Utilities

The fun part is making Tailwind utilities reference your custom properties instead of hardcoded values. In v4's CSS-first config you do this in your @theme block. In v3, you'd do it inside tailwind.config.js under the theme.extend key.

Either way, the pattern is the same — you give Tailwind a value that's a var() reference: ``js // tailwind.config.js (v3 approach) module.exports = { theme: { extend: { colors: { surface: 'var(--color-surface)', 'surface-elevated': 'var(--color-surface-elevated)', accent: 'var(--color-accent)', 'text-primary': 'var(--color-text-primary)', }, spacing: { card: 'var(--spacing-card)', }, }, }, } ` Now you can write bg-surface, text-text-primary, p-card` in your JSX and they'll track your CSS variable values.

One thing to watch: opacity modifiers like bg-surface/50 don't work out of the box with var() colors unless you split your color into channels and use the rgb(var(--color-surface-rgb) / 0.5) pattern. It's a small extra setup step, but absolutely worth it for glassmorphism effects where rgba(255,255,255,0.15) overlays are common. Check out our guide on Tailwind glassmorphism advanced techniques for a full walkthrough of the channel trick.

Building a Multi-Theme System with data-theme Attributes

Most apps need more than two themes. A SaaS product might want light, dark, and a high-contrast mode for accessibility. A white-label tool might need per-tenant brand colors. CSS variables handle all of this without any framework magic.

The pattern: define your baseline tokens on :root, then define overrides for each theme in a [data-theme="x"] block. Apply the attribute to <html> or any ancestor element. You can even nest themes — a dark page with a light sidebar is just a matter of which ancestor carries which attribute.

Switching themes from React becomes trivial: ``tsx // ThemeToggle.tsx export function ThemeToggle() { const toggle = () => { const html = document.documentElement; const current = html.getAttribute('data-theme'); html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark'); }; return ( <button onClick={toggle} className="px-4 py-2 rounded-lg bg-surface-elevated text-text-primary border border-accent/20" > Toggle theme </button> ); } `` That's the whole component. No context. No state management. One DOM attribute write and the cascade does the rest. For a deeper look at theme toggling patterns in React, this article on building a theme toggle in React covers persistence, SSR hydration mismatches, and system preference detection.

Design Tokens at Scale: Organizing Your Variable Namespace

Once you have more than a handful of variables, naming gets messy fast. The most maintainable systems I've seen follow a three-tier structure: primitive tokens, semantic tokens, and component tokens.

Primitive tokens are raw values: --primitive-indigo-500: #6366f1. Semantic tokens give meaning: --color-accent: var(--primitive-indigo-500). Component tokens are the most specific: --button-bg: var(--color-accent). You override at the semantic level for themes, and at the component level for one-off customizations.

The 8px grid principle works beautifully here too. Set --spacing-base: 8px once and define all your spacing as multiples: --spacing-sm: calc(var(--spacing-base) * 1), --spacing-md: calc(var(--spacing-base) * 2), --spacing-lg: calc(var(--spacing-base) * 3). Change the base and your entire layout scales proportionally. That's the kind of thing that saves you hours when a designer comes back and says everything needs to breathe a little more.

If you're moving to Tailwind v4's new features, the @theme directive collapses all three tiers into a single CSS block with zero JavaScript config needed. Worth reading before you start a new project.

Scoped Themes and Component-Level Overrides

Here's where CSS variables genuinely outshine any JavaScript theming approach: scope. You can apply a different theme to any subtree of your DOM without touching global state.

Say you're building a component library (like Empire UI). You want your <Card> component to support a variant="inverted" prop that flips the surface colors. You don't need to reach into your theme provider. Just add a class that overrides the relevant variables: ``tsx // Card.tsx type CardVariant = 'default' | 'inverted' | 'glass'; const variantClasses: Record<CardVariant, string> = { default: '', inverted: '[--color-surface:theme(colors.zinc.900)] [--color-text-primary:theme(colors.zinc.50)]', glass: '[--color-surface:rgba(255,255,255,0.08)] [--color-text-primary:#fafafa]', }; export function Card({ variant = 'default', children }: { variant?: CardVariant; children: React.ReactNode }) { return ( <div className={bg-surface text-text-primary rounded-xl p-card ${variantClasses[variant]}}> {children} </div> ); } ` The [--variable:value]` inline override syntax is a Tailwind v3.4+ feature and it's genuinely useful for this exact pattern.

This is also how you'd implement per-tenant branding in a multi-tenant SaaS app. Fetch the tenant's brand config from your API, set four or five CSS variables on a wrapper element, and every component inside automatically inherits the brand colors. No prop drilling. No CSS-in-JS runtime.

Performance: What the Browser Actually Does

A fair question to ask: does swapping CSS variables cause the browser to repaint the entire page? Yes, in a sense — but it's a style recalculation, not a layout recalculation. The browser recalculates which computed values changed and repaints only affected elements. It doesn't re-run your JavaScript, re-render your React tree, or recalculate layout unless a geometry property actually changed.

Contrast that with a JavaScript theming approach where you're toggling class names on hundreds of elements or triggering a React re-render across your entire component tree. The CSS variable approach wins on every performance metric that matters.

The one scenario where you do want to be careful is animating CSS variables. transition doesn't work directly on custom properties in all browsers yet. The workaround is to transition a property that uses the variable rather than the variable itself, or use @property to register the variable with a type so the browser knows how to interpolate it. The @property rule is now supported in all modern browsers as of 2025, so this is increasingly a non-issue.

Combining CSS Variables with Tailwind's oklch Color Space

Tailwind v4 defaults to oklch for color generation, which plays extremely well with CSS variables. Why? Because oklch gives you perceptually uniform lightness steps, meaning you can programmatically generate an entire color scale from a single hue variable.

Define --hue-brand: 262 and generate your full scale: --color-brand-500: oklch(0.55 0.18 var(--hue-brand)). Change the hue variable and your entire brand palette shifts. This is dramatically simpler than maintaining separate hex values for every shade.

This technique pairs well with the design token structure described earlier. Your primitive tokens define the hue, chroma, and lightness values. Your semantic tokens compose them into named colors. Tailwind utilities reference the semantic tokens. The full chain stays in CSS — no build step needed to update a theme. If you want to go deeper on the oklch angle, our article on Tailwind oklch colors goes into the math and shows you how to build an accessible contrast checker on top of it.

FAQ

Do CSS variables work with Tailwind's JIT engine and arbitrary values?

Yes. You can use var() references in arbitrary value syntax like bg-[var(--color-surface)] or text-[var(--color-text-primary)]. JIT generates the utility class on demand. That said, you'll get better DX by mapping your variables to named utilities in your Tailwind config so you get autocomplete and shorter class names.

How do I persist the user's theme choice across page reloads?

Store the chosen theme in localStorage and read it on initial load. For SSR (Next.js, Remix), apply the data-theme attribute in a script tag in your <head> before React hydrates — this prevents the flash of wrong theme. Something like document.documentElement.setAttribute('data-theme', localStorage.getItem('theme') || 'light') in an inline script block works well.

Can I use CSS variables with Tailwind's dark mode class strategy?

Absolutely. Set darkMode: 'class' in your Tailwind config and use dark: prefixed utilities normally. But the CSS variable approach is complementary: you can define your variable overrides under .dark instead of [data-theme='dark'] and get the same result. The variable approach just gives you more flexibility for multiple themes beyond a simple light/dark toggle.

Why don't opacity modifiers like `bg-surface/50` work with CSS variable colors?

Tailwind's opacity modifier works by injecting rgb(R G B / 0.5) syntax, which requires separate channel values. If your variable is var(--color-surface) and resolves to a hex or full rgb value, the browser can't split it into channels. The fix: define your color as separate channel variables — --color-surface-rgb: 255 255 255 — then reference it as rgb(var(--color-surface-rgb) / 0.5) in your Tailwind color config.

Is there a performance cost to having hundreds of CSS variables on :root?

Negligible in practice. CSS variables are stored in the cascade just like any other computed property. Having 200 variables on :root won't noticeably impact style recalculation time. The only real cost is the initial CSS parse, and since variables are just strings until they're used, even that's minimal.

How do CSS variables interact with Tailwind v4's @theme directive?

In v4, @theme is the CSS-first replacement for the JavaScript theme config. Variables you define inside @theme become both CSS custom properties on :root and Tailwind utility values simultaneously. So --color-accent: #6366f1 inside @theme gives you both var(--color-accent) in CSS and bg-accent, text-accent utilities in your markup. It's a cleaner model than v3 once you get used to it.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Customizing shadcn/ui: Colors, Radius, and Dark Mode TokensTailwind Dark Mode: class vs media, system preference, manual toggleDark Mode in a Design System: Semantic Tokens That WorkCSS Variables as Design Tokens: The Complete Implementation