HandoffPro

Design Tokens with CSS Variables: Full Guide

·9 min read

TLDR

CSS variables (custom properties) are the most common implementation for design tokens because they enable runtime theming without JavaScript, work with any CSS methodology, and provide native browser support. A complete CSS variable token system includes primitive tokens (raw values), semantic tokens (contextual mappings), and scoped overrides (component-specific variations).

Key takeaways:

  • CSS variables enable theming and token switching at runtime without re-rendering
  • Two-layer architecture (primitive + semantic tokens) provides flexibility and maintainability
  • Kebab-case naming with hierarchical prefixes prevents conflicts and improves readability
  • Dark mode implementation uses data attributes or media queries to swap token sets
  • Style Dictionary automates conversion from JSON tokens to CSS custom properties
  • Scoped overrides enable component-specific token customization without creating new tokens

Why CSS Variables for Design Tokens?

CSS variables offer four critical advantages over hard-coded values or JavaScript token objects:

1. Native Browser Support – CSS variables work natively in all modern browsers without any build step, polyfill, or runtime library. Define once, use everywhere.

2. No Build Step Required – Unlike Sass variables or CSS-in-JS solutions, CSS variables update at runtime. Change --color-primary and every element using that token updates instantly without recompiling.

3. Scoped Overrides – CSS variables cascade like any other CSS property, enabling component-level overrides. A .card component can redefine --color-bg without affecting other components.

4. Runtime Theming – Switch themes by changing variable values on :root or a data attribute. No JavaScript re-renders, no FOUC (flash of unstyled content), just instant visual updates.

These benefits make CSS variables the industry-standard approach for implementing design tokens in web applications.

Token Structure: Primitive vs Semantic

The most maintainable token systems use a two-layer architecture: primitive tokens define raw values, while semantic tokens assign contextual meaning.

Primitive tokens are absolute values without context:

:root {
  /* Primitive color tokens - raw values */
  --color-blue-50: #EFF6FF;
  --color-blue-500: #3B82F6;
  --color-blue-900: #1E3A8A;
  --color-gray-50: #F8FAFC;
  --color-gray-500: #64748B;
  --color-gray-900: #0F172A;
  --color-red-500: #EF4444;
  --color-green-500: #10B981;
}

Semantic tokens reference primitives and assign meaning:

:root {
  /* Semantic color tokens - contextual mappings */
  --color-brand-primary: var(--color-blue-500);
  --color-brand-secondary: var(--color-blue-900);
  --color-text-primary: var(--color-gray-900);
  --color-text-secondary: var(--color-gray-500);
  --color-bg-primary: #FFFFFF;
  --color-bg-secondary: var(--color-gray-50);
  --color-success: var(--color-green-500);
  --color-error: var(--color-red-500);
}

Why two layers matter: When rebranding or implementing dark mode, you update semantic mappings without touching primitive values. For example, switching --color-brand-primary from var(--color-blue-500) to var(--color-purple-500) updates every branded element across your application instantly.

Components should always consume semantic tokens, never primitives:

/* ✅ GOOD: Use semantic tokens */
.button-primary {
  background-color: var(--color-brand-primary);
  color: white;
}
 
/* ❌ BAD: Use primitive tokens directly */
.button-primary {
  background-color: var(--color-blue-500);
  color: white;
}

This separation enables theming without touching component styles.

Naming Conventions

Consistent naming is critical for maintainable design token systems. Here are three common patterns:

1. Flat Kebab-Case (Recommended)

--color-primary
--color-secondary
--spacing-sm
--spacing-md

Advantages: Clean, readable, sorts well in DevTools Disadvantages: No visual grouping

2. Hierarchical Kebab-Case (Best)

--color-brand-primary
--color-brand-secondary
--color-text-primary
--color-bg-surface
--spacing-4
--spacing-8

Advantages: Clear namespacing, prevents conflicts, groups related tokens Disadvantages: Longer names

3. BEM-Style (Avoid for Tokens)

--color--primary
--color__brand--primary

Disadvantages: Tokens aren't components, BEM semantics don't apply

Recommendation: Use hierarchical kebab-case with category prefixes (color-, spacing-, font-, etc.). This provides clear namespacing while remaining readable and sortable.

Include scale suffixes for size-based tokens:

/* Spacing scale */
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-4: 16px;
--spacing-8: 32px;
 
/* Font size scale */
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;

Numeric scales (1, 2, 4, 8) work well for spacing where values have mathematical relationships. Named scales (xs, sm, base, lg, xl) work better for typography where sizes are contextual.

Code Example: Complete Token System

Here's a production-ready CSS token system with primitives, semantics, and component tokens:

/* tokens.css */
 
/* ============================================
   PRIMITIVE TOKENS (Raw Values)
   ============================================ */
 
:root {
  /* Primitive colors */
  --color-blue-50: #EFF6FF;
  --color-blue-100: #DBEAFE;
  --color-blue-500: #3B82F6;
  --color-blue-600: #2563EB;
  --color-blue-900: #1E3A8A;
 
  --color-gray-50: #F8FAFC;
  --color-gray-100: #F1F5F9;
  --color-gray-200: #E2E8F0;
  --color-gray-300: #CBD5E1;
  --color-gray-500: #64748B;
  --color-gray-700: #334155;
  --color-gray-900: #0F172A;
 
  --color-red-500: #EF4444;
  --color-red-600: #DC2626;
  --color-green-500: #10B981;
  --color-yellow-500: #F59E0B;
 
  /* Primitive spacing */
  --spacing-0: 0;
  --spacing-1: 4px;
  --spacing-2: 8px;
  --spacing-3: 12px;
  --spacing-4: 16px;
  --spacing-5: 20px;
  --spacing-6: 24px;
  --spacing-8: 32px;
  --spacing-10: 40px;
  --spacing-12: 48px;
  --spacing-16: 64px;
 
  /* Primitive typography */
  --font-family-sans: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-family-mono: 'JetBrains Mono', 'Fira Code', monospace;
 
  --font-size-xs: 12px;
  --font-size-sm: 14px;
  --font-size-base: 16px;
  --font-size-lg: 18px;
  --font-size-xl: 20px;
  --font-size-2xl: 24px;
  --font-size-3xl: 30px;
 
  --font-weight-normal: 400;
  --font-weight-medium: 500;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;
 
  --line-height-tight: 1.25;
  --line-height-normal: 1.5;
  --line-height-relaxed: 1.75;
 
  /* Primitive borders */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;
  --radius-full: 9999px;
 
  --border-width-1: 1px;
  --border-width-2: 2px;
 
  /* Primitive shadows */
  --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-sm: 0 2px 4px 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.1);
  --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
}
 
/* ============================================
   SEMANTIC TOKENS (Contextual Mappings)
   ============================================ */
 
:root {
  /* Brand colors */
  --color-brand-primary: var(--color-blue-500);
  --color-brand-primary-hover: var(--color-blue-600);
  --color-brand-secondary: var(--color-blue-900);
 
  /* Background colors */
  --color-bg-primary: #FFFFFF;
  --color-bg-secondary: var(--color-gray-50);
  --color-bg-tertiary: var(--color-gray-100);
  --color-bg-elevated: #FFFFFF;
 
  /* Text colors */
  --color-text-primary: var(--color-gray-900);
  --color-text-secondary: var(--color-gray-700);
  --color-text-tertiary: var(--color-gray-500);
  --color-text-inverse: #FFFFFF;
 
  /* Border colors */
  --color-border-primary: var(--color-gray-200);
  --color-border-secondary: var(--color-gray-300);
  --color-border-focus: var(--color-brand-primary);
 
  /* Semantic/feedback colors */
  --color-success: var(--color-green-500);
  --color-error: var(--color-red-500);
  --color-warning: var(--color-yellow-500);
  --color-info: var(--color-blue-500);
 
  /* Component-level defaults */
  --button-padding-x: var(--spacing-4);
  --button-padding-y: var(--spacing-2);
  --button-radius: var(--radius-md);
  --input-height: var(--spacing-10);
  --card-padding: var(--spacing-6);
  --card-radius: var(--radius-lg);
}
 
/* ============================================
   COMPONENT SCOPED OVERRIDES
   ============================================ */
 
.card {
  background: var(--color-bg-elevated);
  border: var(--border-width-1) solid var(--color-border-primary);
  border-radius: var(--card-radius);
  padding: var(--card-padding);
  box-shadow: var(--shadow-sm);
}
 
.card--subtle {
  --color-bg-elevated: var(--color-bg-secondary);
  --shadow-sm: none;
}
 
.button {
  font-family: var(--font-family-sans);
  font-size: var(--font-size-base);
  font-weight: var(--font-weight-semibold);
  padding: var(--button-padding-y) var(--button-padding-x);
  border-radius: var(--button-radius);
  border: none;
  cursor: pointer;
  transition: background-color 0.2s ease;
}
 
.button--primary {
  background-color: var(--color-brand-primary);
  color: var(--color-text-inverse);
}
 
.button--primary:hover {
  background-color: var(--color-brand-primary-hover);
}

This system demonstrates:

  1. Clear separation between primitive and semantic tokens
  2. Component-level token definitions for reusable spacing/sizing
  3. Scoped overrides using modifier classes (.card--subtle)
  4. Consistent hierarchical naming

Dark Mode with CSS Variables

CSS variables excel at runtime theming. Here's a complete dark mode implementation:

/* Light theme (default) */
:root {
  --color-bg-primary: #FFFFFF;
  --color-bg-secondary: #F8FAFC;
  --color-text-primary: #0F172A;
  --color-text-secondary: #64748B;
  --color-border-primary: #E2E8F0;
  --color-brand-primary: #3B82F6;
}
 
/* Dark theme override */
[data-theme='dark'] {
  --color-bg-primary: #0F172A;
  --color-bg-secondary: #1E293B;
  --color-text-primary: #F8FAFC;
  --color-text-secondary: #94A3B8;
  --color-border-primary: #334155;
  --color-brand-primary: #60A5FA; /* Lighter shade for dark backgrounds */
}
 
/* Alternative: System preference detection */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-primary: #0F172A;
    --color-bg-secondary: #1E293B;
    --color-text-primary: #F8FAFC;
    --color-text-secondary: #94A3B8;
    --color-border-primary: #334155;
    --color-brand-primary: #60A5FA;
  }
}

Toggle dark mode with JavaScript:

// Toggle dark mode
function toggleDarkMode() {
  const currentTheme = document.documentElement.getAttribute('data-theme');
  const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', newTheme);
  localStorage.setItem('theme', newTheme);
}
 
// Load saved theme preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
  document.documentElement.setAttribute('data-theme', savedTheme);
}

The beauty of this approach: component styles never change. Every component continues using var(--color-bg-primary), but the resolved value switches from white to dark blue when the data-theme attribute changes.

Automated Conversion: JSON to CSS

Maintaining CSS variables manually doesn't scale for large design systems. Use Style Dictionary to generate CSS from JSON tokens:

Input: tokens.json

{
  "color": {
    "brand": {
      "primary": { "value": "#3B82F6" },
      "secondary": { "value": "#8B5CF6" }
    },
    "gray": {
      "50": { "value": "#F8FAFC" },
      "500": { "value": "#64748B" },
      "900": { "value": "#0F172A" }
    }
  },
  "spacing": {
    "1": { "value": "4px" },
    "4": { "value": "16px" },
    "8": { "value": "32px" }
  }
}

Config: style-dictionary.config.js

module.exports = {
  source: ['tokens.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      buildPath: 'dist/',
      files: [{
        destination: 'tokens.css',
        format: 'css/variables',
        options: {
          outputReferences: true // Use var() references where possible
        }
      }]
    }
  }
};

Output: dist/tokens.css

:root {
  --color-brand-primary: #3B82F6;
  --color-brand-secondary: #8B5CF6;
  --color-gray-50: #F8FAFC;
  --color-gray-500: #64748B;
  --color-gray-900: #0F172A;
  --spacing-1: 4px;
  --spacing-4: 16px;
  --spacing-8: 32px;
}

Run style-dictionary build to regenerate CSS whenever JSON tokens change. This workflow enables design teams to update token values in JSON format while developers consume generated CSS variables.

For cross-platform projects, Style Dictionary can simultaneously generate CSS variables, iOS Swift constants, Android XML resources, and React Native JavaScript from the same token source.

Scoped Overrides for Components

One powerful feature of CSS variables is their ability to be overridden in specific scopes. This enables contextual variations without creating new tokens:

/* Global card tokens */
:root {
  --card-bg: var(--color-bg-primary);
  --card-border: var(--color-border-primary);
  --card-padding: var(--spacing-6);
}
 
/* Default card using global tokens */
.card {
  background: var(--card-bg);
  border: 1px solid var(--card-border);
  padding: var(--card-padding);
  border-radius: var(--radius-lg);
}
 
/* Sidebar cards get different background */
.sidebar .card {
  --card-bg: var(--color-bg-elevated);
}
 
/* Compact cards get reduced padding */
.card--compact {
  --card-padding: var(--spacing-4);
}

This pattern is extremely powerful: you define tokens once at the root, but components can override specific tokens for contextual variations. The .card CSS never changes—only the token values.

This approach is central to design system implementation, enabling consistent components with flexible contextual adaptations.

FAQ

Q: What is the browser support for CSS variables?

A: CSS variables are supported in all modern browsers since 2017, including Chrome 49+, Firefox 31+, Safari 9.1+, and Edge 15+. Internet Explorer 11 does not support CSS variables, but IE11 reached end-of-life in June 2022. For legacy browser support, use PostCSS plugins to provide static fallback values.

Q: What naming convention should I use for CSS variable design tokens?

A: Use kebab-case with hierarchical prefixes like --color-brand-primary, --spacing-4, --font-size-lg. This provides clear namespacing, avoids conflicts with third-party libraries, and sorts logically in browser DevTools. Avoid BEM notation for tokens as they represent values, not components.

Q: Do CSS variables impact performance?

A: CSS variables have negligible performance impact in modern browsers. They're part of the CSSOM and resolved during style calculation, not during paint or layout. Using CSS variables for theming is significantly faster than JavaScript-based token switching because browsers can update all references in a single paint cycle.

Stop Extracting Design Values Manually

Upload a Figma screenshot and get JSONC tokens + a Claude-ready prompt in 30 seconds.

Cookie Preferences

We use cookies for analytics and advertising. Essential cookies are always enabled. Learn more

Design Tokens with CSS Variables: Full Guide | HandoffPro