CSS Variables as Design Tokens: The Complete Implementation
CSS custom properties are the backbone of scalable design systems. Here's how to implement design tokens with CSS variables that actually work across themes, components, and teams.
Why CSS Variables Beat Every Other Token Approach
Honestly, most design token implementations I've seen are over-engineered to the point of being unusable. Teams reach for JSON configs, build-time transforms, and multi-platform token tools when the browser already ships exactly what they need: CSS custom properties.
CSS variables — officially called custom properties — have been in every major browser since 2016. They're inherited, they cascade, they're inspectable in DevTools, and they update at runtime without a single JavaScript call. That's not a minor detail. That's the whole point.
The design token space got complicated fast. Style Dictionary, Theo, Tokens Studio — these tools solve real problems at large scale. But if you're building a React component library or a SaaS product, you can get 90% of the benefit with a flat CSS file and a clear naming convention. Let's talk about how.
Structuring Your Token Hierarchy: Primitive, Semantic, Component
The single most important architectural decision you'll make is the three-tier token hierarchy. Skip it and you'll end up with --blue-500 sprinkled everywhere, making a rebrand a nightmare. Get it right and you'll wonder how you ever worked without it.
Primitive tokens are raw values — no context, no opinion. --color-blue-500: #3b82f6; is a primitive. It describes what something *is*, not what it *does*. Semantic tokens map meaning onto primitives: --color-interactive-primary: var(--color-blue-500);. Component tokens are the last layer: --button-bg: var(--color-interactive-primary);.
This three-layer approach means you can retheme an entire product by changing one semantic token. Swap --color-interactive-primary to point at --color-purple-500 and every button, link, and focus ring updates. No find-and-replace. No missed instances.
Implementing a Token File That Actually Scales
Here's a token file structure that I've used in production across multiple design systems. It separates concerns cleanly and plays well with both plain CSS and Tailwind v4.0.2's @theme directive.
/* tokens.css */
:root {
/* === PRIMITIVES === */
/* Color */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-900: #111827;
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
/* Spacing scale (base: 4px) */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
/* Type scale */
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-2xl: 1.5rem;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* === SEMANTIC === */
--color-text-primary: var(--color-gray-900);
--color-text-secondary: var(--color-gray-600);
--color-surface-default: #ffffff;
--color-surface-subtle: var(--color-gray-50);
--color-border-default: var(--color-gray-200);
--color-interactive-primary: var(--color-blue-500);
--color-interactive-primary-hover: var(--color-blue-600);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-overlay: 0 20px 25px rgba(0, 0, 0, 0.1), 0 8px 10px rgba(0, 0, 0, 0.04);
}
/* Dark mode overrides — semantic layer only */
[data-theme="dark"] {
--color-text-primary: var(--color-gray-50);
--color-text-secondary: var(--color-gray-400);
--color-surface-default: #0f172a;
--color-surface-subtle: #1e293b;
--color-border-default: var(--color-gray-700);
--color-interactive-primary: var(--color-blue-400);
--color-interactive-primary-hover: var(--color-blue-300);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-overlay: 0 20px 25px rgba(0, 0, 0, 0.5), 0 8px 10px rgba(0, 0, 0, 0.4);
}Notice that the dark mode block only overrides semantic tokens — never primitives. This is intentional. Primitives are immutable references. If you start overriding --color-blue-500 in dark mode, you've broken the primitive contract and things get confusing fast.
Wiring Tokens into React Components
CSS variables shine brightest when consumed in component styles. The pattern is straightforward: components reference semantic tokens, never primitives. This keeps component code stable even when your brand color palette changes completely.
// Button.tsx
import styles from './Button.module.css';
type ButtonVariant = 'primary' | 'ghost' | 'danger';
interface ButtonProps {
variant?: ButtonVariant;
children: React.ReactNode;
onClick?: () => void;
}
export function Button({ variant = 'primary', children, onClick }: ButtonProps) {
return (
<button
className={styles.button}
data-variant={variant}
onClick={onClick}
>
{children}
</button>
);
}
```
```css
/* Button.module.css */
.button {
display: inline-flex;
align-items: center;
gap: var(--space-2); /* 8px gap */
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
/* Default: primary variant */
background-color: var(--color-interactive-primary);
color: #ffffff;
}
.button:hover {
background-color: var(--color-interactive-primary-hover);
}
.button[data-variant='ghost'] {
background-color: transparent;
color: var(--color-interactive-primary);
border-color: var(--color-border-default);
}
.button[data-variant='ghost']:hover {
background-color: var(--color-surface-subtle);
}
.button[data-variant='danger'] {
background-color: var(--color-red-600, #dc2626);
}The data-variant attribute approach avoids CSS Modules' class composition quirks and makes variants readable in the DOM. It's also easier to debug in browser DevTools — you can see exactly which variant is active without hunting through generated class names.
Integrating CSS Tokens with Tailwind v4
Tailwind v4.0.2 introduced the @theme directive, which lets you define your design system tokens once and have them available as both CSS variables and Tailwind utility classes. This is genuinely good. You define --color-interactive-primary in @theme and you can use it as both var(--color-interactive-primary) in raw CSS and bg-interactive-primary in JSX.
/* app.css */
@import "tailwindcss";
@theme {
--color-interactive-primary: #3b82f6;
--color-interactive-primary-hover: #2563eb;
--color-surface-default: #ffffff;
--color-surface-subtle: #f9fafb;
--color-text-primary: #111827;
--color-border-default: #e5e7eb;
--spacing-gap-sm: 0.5rem;
--spacing-gap-md: 1rem;
--radius-component: 8px;
--font-size-label: 0.875rem;
}One gotcha: Tailwind v4's @theme tokens don't automatically inherit the dark mode overrides you set with [data-theme="dark"]. You'll need to pair it with a theme toggle implementation that sets the data-theme attribute on your root element. The CSS variable cascade handles the rest — Tailwind utility classes pick up the overridden values automatically since they're just var() references under the hood.
If you're building a spacing system alongside your color tokens, keep spacing as raw rem values in @theme too. Don't mix px and rem in the same scale — pick one and commit. I use 4px (0.25rem) as the base unit.
Runtime Theming: Changing Tokens with JavaScript
Here's where CSS variables leave static preprocessor solutions in the dust. You can update any token at runtime, instantly, with one line of JavaScript. Want to let users pick an accent color? You don't need to regenerate a stylesheet or swap a <link> tag.
// themeManager.ts
type ThemeMode = 'light' | 'dark' | 'system';
export function setThemeMode(mode: ThemeMode): void {
const root = document.documentElement;
if (mode === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
root.setAttribute('data-theme', mode);
}
localStorage.setItem('theme-preference', mode);
}
export function setAccentColor(hex: string): void {
document.documentElement.style.setProperty(
'--color-interactive-primary',
hex
);
// Derive a slightly darker hover state
document.documentElement.style.setProperty(
'--color-interactive-primary-hover',
darkenHex(hex, 10)
);
}
function darkenHex(hex: string, percent: number): string {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, (num >> 16) - Math.round(255 * percent / 100));
const g = Math.max(0, ((num >> 8) & 0xff) - Math.round(255 * percent / 100));
const b = Math.max(0, (num & 0xff) - Math.round(255 * percent / 100));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}This runtime approach is what makes user-customizable themes possible without server-side rendering tricks. White-label SaaS products use exactly this pattern — they store a brand color in their database and call setAccentColor on page load. The whole UI updates before the first paint.
Token Naming Conventions That Don't Break in Six Months
Naming is where most token systems fall apart. Too specific and names become brittle. Too abstract and nobody knows what to use. The convention I keep coming back to: --[category]-[role]-[variant]-[state]. Category is color, space, font, radius, shadow. Role is the semantic use. Variant is the weight or size. State is optional — hover, active, disabled.
So: --color-text-primary, --color-text-secondary, --color-text-disabled. Or --space-stack-md for vertical spacing, --space-inline-sm for horizontal. This reads clearly in CSS without any mental mapping required. You might also want to read about the color system approach for a deeper look at color ramp generation.
What about tokens for specific components? Keep them at the component layer only and prefix with the component name: --card-bg, --card-border-radius, --card-padding. These should always reference semantic tokens as fallbacks: --card-bg: var(--color-surface-default). That way your card inherits from the theme automatically, but a consumer can override it locally without touching the global tokens.
And don't obsess over completeness on day one. Start with 20-30 tokens that cover color, spacing, and typography. Add tokens when you notice duplication. A token system that's actually used beats a 200-token system that nobody can remember. How many times have you seen a Figma token library with 400 entries that the dev team ignores entirely?
Testing and Documenting Your Token System
Tokens without documentation are tokens nobody uses correctly. The good news is that CSS variables are self-documenting at the browser level — pop open DevTools, filter the -- prefix, and you see everything. But that's not enough for a team.
If you're using Storybook, a Storybook component library setup makes token documentation trivial. Create a dedicated Tokens story that renders swatches, spacing previews, and type specimens directly from your CSS variables. The story reads the values at runtime using getComputedStyle(document.documentElement).getPropertyValue('--color-interactive-primary'), so it's always up to date.
For accessibility validation — and you should be validating — pull token values into a contrast checker during your CI pipeline. A --color-text-primary on --color-surface-default combination should always meet WCAG AA at minimum. The WCAG accessibility guide covers the contrast ratios in detail, but the short version: 4.5:1 for normal text, 3:1 for large text. Automate this check or it won't happen consistently.
FAQ
CSS variables for tokens, JS-in-CSS for component variants if you need it. Tokens are global, shared values — CSS custom properties handle that natively with zero runtime cost. JS-in-CSS libraries are useful for dynamic props that change per-instance, but they're overkill for a shared color palette or spacing scale.
CSS variables are resolved by the browser, not the server, so SSR doesn't affect token values. The only gotcha is flash of unstyled theme (FOUT) when using dark mode. Fix it by reading the user's saved preference in a blocking script tag in <head> and setting data-theme before the browser paints. Next.js 15's cookies() API can also handle this server-side if you store the preference in a cookie.
Yes, and it's a great combination. Tailwind v4.0.2 introduced the @theme directive that maps CSS custom properties to utility classes automatically. Define --color-brand: #6366f1 in @theme and you get both var(--color-brand) for raw CSS and bg-brand, text-brand utilities for Tailwind. Dark mode overrides still live in your CSS using [data-theme='dark'] or @media (prefers-color-scheme: dark).
Negligible in practice. Browsers resolve CSS variable references during style computation, same as any other property. The overhead is only measurable if you're doing extremely high-frequency DOM updates (think canvas-level animation), which you wouldn't be doing with CSS variables anyway. Hundreds of tokens on :root won't cause any perceptible slowdown.
Set the variable on the component's root element instead of :root. A .card class can define --card-padding: 24px and that value is only accessible within .card descendants. You can also scope with web components using the :host selector. This is exactly how CSS variable scoping is supposed to work — it cascades down, not up.
No, and this is a hard rule worth enforcing. If a component references --color-blue-500 directly, you've created a hidden coupling. Retheme your brand to use purple and you'll miss that instance. Components should only reference semantic tokens (--color-interactive-primary) or component-level tokens (--button-bg). Primitives are for building the semantic layer only.