Design Tokens with CSS Custom Properties
How to build a design token system using CSS custom properties, including color scales, spacing, typography, themes, and responsive scaling.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
CSS custom properties (variables) let you define design tokens — named values for colors, spacing, typography, and other design decisions. Unlike preprocessor variables, custom properties are live: they can change at runtime, respond to media queries, and cascade through the DOM. This makes them ideal for theming, responsive design, and creating consistent design systems without JavaScript or build-time compilation.
When to Use
- Building a design system with consistent spacing, colors, and typography
- Supporting multiple themes (light/dark, brand variants)
- Responsive scaling — adjusting spacing and font sizes at different breakpoints
- Creating reusable component libraries that adapt to the host application’s tokens
- Runtime theme switching without page reload
When NOT to Use
- Legacy browser support — custom properties don’t work in IE11
- Values that never change and don’t need theming — a hardcoded
z-indexis fine - Complex color manipulation — use
color-mix()or preprocessor functions for dynamic color math
Solution
Basic token definitions
:root {
/* Color scale */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-900: #1e3a8a;
/* Neutral scale */
--color-white: #ffffff;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-500: #6b7280;
--color-gray-900: #111827;
--color-black: #000000;
/* Semantic tokens */
--color-bg: var(--color-white);
--color-text: var(--color-gray-900);
--color-text-muted: var(--color-gray-500);
--color-border: var(--color-gray-200);
--color-accent: var(--color-primary-500);
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
/* Typography */
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.15);
}
Using tokens in components
.card {
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--line-height-normal);
}
.card__title {
font-size: var(--text-xl);
font-weight: 600;
margin-bottom: var(--space-2);
}
.card__body {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
Dark theme with semantic tokens
:root {
--color-bg: var(--color-white);
--color-text: var(--color-gray-900);
--color-text-muted: var(--color-gray-500);
--color-border: var(--color-gray-200);
--color-surface: var(--color-white);
--color-surface-hover: var(--color-gray-100);
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
--color-border: #1e293b;
--color-surface: #1e293b;
--color-surface-hover: #334155;
}
Toggle theme by setting data-theme on the root element:
document.documentElement.setAttribute("data-theme", "dark");
Responsive spacing with media queries
:root {
--space-page: var(--space-4);
--text-heading: var(--text-xl);
}
@media (min-width: 768px) {
:root {
--space-page: var(--space-8);
--text-heading: var(--text-2xl);
}
}
@media (min-width: 1280px) {
:root {
--space-page: var(--space-12);
--text-heading: var(--text-2xl);
}
}
.page {
padding: var(--space-page);
}
.page__title {
font-size: var(--text-heading);
}
Fluid typography with clamp
:root {
--text-fluid-sm: clamp(0.875rem, 0.8rem + 0.25vw, 1rem);
--text-fluid-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
--text-fluid-lg: clamp(1.25rem, 1rem + 1vw, 1.5rem);
--text-fluid-xl: clamp(1.5rem, 1.2rem + 1.5vw, 2rem);
--text-fluid-2xl: clamp(2rem, 1.5rem + 2.5vw, 3rem);
}
h1 { font-size: var(--text-fluid-2xl); }
h2 { font-size: var(--text-fluid-xl); }
h3 { font-size: var(--text-fluid-lg); }
p { font-size: var(--text-fluid-base); }
Component-scoped tokens
.button {
--button-bg: var(--color-primary-500);
--button-text: var(--color-white);
--button-padding-x: var(--space-4);
--button-padding-y: var(--space-2);
--button-radius: var(--radius-md);
background: var(--button-bg);
color: var(--button-text);
padding: var(--button-padding-y) var(--button-padding-x);
border-radius: var(--button-radius);
border: none;
cursor: pointer;
}
.button--secondary {
--button-bg: var(--color-gray-100);
--button-text: var(--color-gray-900);
}
.button--large {
--button-padding-x: var(--space-6);
--button-padding-y: var(--space-3);
}
Z-index scale
:root {
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
}
.dropdown { z-index: var(--z-dropdown); }
.modal { z-index: var(--z-modal); }
.toast { z-index: var(--z-toast); }
Transition tokens
:root {
--transition-fast: 150ms ease-out;
--transition-base: 250ms ease-out;
--transition-slow: 400ms ease-out;
}
.button {
transition: background var(--transition-fast), transform var(--transition-fast);
}
.button:hover {
background: var(--color-primary-600);
transform: translateY(-1px);
}
Using tokens with Tailwind CSS v4
@theme {
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-900: #1e3a8a;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-4: 1rem;
--spacing-6: 1.5rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
}
Tailwind v4 reads these tokens and generates utilities like bg-brand-500, p-6, rounded-md.
Variants
Using tokens in JavaScript
function getToken(name) {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
}
const primaryColor = getToken("--color-primary-500");
const spaceUnit = getToken("--space-4");
Theme switching with localStorage
const theme = localStorage.getItem("theme") || "light";
document.documentElement.setAttribute("data-theme", theme);
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
}
Brand-specific tokens
[data-brand="acme"] {
--color-primary-500: #e11d48;
--color-primary-600: #be123c;
}
[data-brand="globex"] {
--color-primary-500: #059669;
--color-primary-600: #047857;
}
Best Practices
-
For a deeper guide, see Dark Mode with prefers-color-scheme and CSS Variables.
-
Use semantic token names (
--color-bg,--color-text) instead of raw color names (--blue-500) — semantic tokens make theming trivial -
Define raw color scales separately from semantic tokens — raw scales are the palette, semantic tokens are the meaning
-
Keep the spacing scale consistent — use a base unit (0.25rem) and multiply
-
Use
clamp()for fluid typography — it adapts smoothly across screen sizes -
Group tokens by category (colors, spacing, typography, shadows) with comments
-
Use component-scoped tokens for complex components — override parent tokens locally
-
Test both themes during development — don’t build the entire UI in light mode then try to add dark mode
Common Mistakes
- Using raw color values in components:
color: #3b82f6instead ofcolor: var(--color-accent)— theming becomes impossible. - Hardcoding spacing:
padding: 14pxinstead ofpadding: var(--space-3)— inconsistent spacing across the app. - Not defining semantic tokens: mapping components directly to raw scale tokens (
--color-primary-500) makes theme changes require updating every component. - Overriding tokens at component level without scoping: setting
--color-bgglobally inside a.cardrule affects all descendants unexpectedly. - Not testing dark theme: some token combinations look fine in light mode but have poor contrast in dark mode.
FAQ
What is the difference between raw tokens and semantic tokens?
Raw tokens are the palette (--color-blue-500: #3b82f6). Semantic tokens assign meaning (--color-accent: var(--color-blue-500)). Components use semantic tokens. To change the accent color, you update the semantic token, not every component.
Can I use custom properties with preprocessors?
Yes. Sass/LESS variables are compile-time; custom properties are runtime. You can use both — Sass for static values, custom properties for themeable values.
How do I support IE11?
You can’t use custom properties in IE11. Use @supports to provide fallbacks:
.card { background: #ffffff; }
@supports (--css: variables) {
.card { background: var(--color-bg); }
}
How do custom properties cascade?
Like regular CSS properties. A token defined on :root applies to all elements. A token defined on .dark-theme overrides the :root value for elements inside .dark-theme.
Should I use custom properties or Tailwind’s @theme?
Tailwind v4’s @theme uses custom properties internally. Use @theme if you’re using Tailwind — it generates utilities from your tokens. Use raw custom properties if you’re not using Tailwind.
Related Resources
Container Queries for Component Responsiveness
How to use CSS container queries for component-level responsive layouts that adapt to their container size instead of the viewport.
RecipeDark Mode with prefers-color-scheme and CSS Variables
How to implement dark mode using CSS prefers-color-scheme media query, CSS custom properties, and manual toggle with localStorage persistence.
RecipeExhaustive Type Checking with Discriminated Unions
How to use TypeScript discriminated unions for exhaustive type checking, ensuring all cases are handled at compile time with never type assertions.
GuideComplete Guide to Web Accessibility: WCAG 2.2 Compliance
Master web accessibility with WCAG 2.2: ARIA roles, keyboard navigation, screen reader support, color contrast, focus management, and accessible forms.