Front-End · 10 min read · Updated

CSS Custom Properties as Design Tokens: A Practical Guide

Turn colours, spacing and type sizes into CSS custom properties that mirror your Figma Variables – with token layers, theming, dark mode and a live demo.

Design tokens: color Swatches Book
Photo via Rawpixel (CC0)

Design tokens are the small, named decisions behind an interface: the brand blue, the spacing step, the corner radius of a button, the size of body text. In Figma they often live as Variables. In code, the most natural home for them is CSS custom properties – what most people call CSS variables.

When the two match, a designer can say “use color/text/muted” and the developer writes var(--color-text-muted). Changes happen in one place, themes become simple, and the product looks consistent. This guide shows how to set up design tokens in plain CSS.

Tip: If custom properties are new to you, read the CSS variables lesson first. For the design side, see Design Systems for Beginners.

Custom properties in thirty seconds

:root {
  --color-brand: #1d4ed8;
  --space-4: 1rem;
}

.button {
  background: var(--color-brand);
  padding: var(--space-4);
  border-radius: var(--radius-md, 8px); /* 8px if --radius-md is not defined */
}

Three things make custom properties more powerful than Sass variables:

  • They exist in the browser at runtime. You can change them with a class, a media query or JavaScript, and everything using them updates instantly.
  • They inherit. A value set on :root reaches every element; a value set on .card applies only inside that card.
  • They cascade. Normal CSS rules – specificity, order, media queries – decide which value wins.

Token layers: primitive, semantic, component

The most common mistake is jumping straight to --blue-600 everywhere. That works until the brand changes, or dark mode arrives and “blue-600” is no longer the right colour for links. Most design systems organise their design tokens in layers:

LayerExampleAnswers the question
Primitive (or base)--blue-600: #1d4ed8;What colours, sizes and fonts do we have?
Semantic (or alias)--color-link: var(--blue-600);What is each value for?
Component--button-bg: var(--color-action);What does this specific component use?

Components should use semantic tokens, never primitives directly. Then a theme only has to re-point the semantic layer, and every component follows.

/* 1. Primitives: the palette and scales */
:root {
  --blue-600: #1d4ed8;
  --blue-300: #93c5fd;
  --slate-900: #0f172a;
  --slate-600: #475569;
  --slate-100: #f1f5f9;
  --white: #ffffff;

  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --space-6: 1.5rem;

  --radius-sm: 4px;
  --radius-md: 8px;
  --font-size-body: 1rem;
  --font-size-h2: clamp(1.4rem, 1.1rem + 1.2vw, 2rem);
}

/* 2. Semantic: roles */
:root {
  --color-bg: var(--white);
  --color-surface: var(--slate-100);
  --color-text: var(--slate-900);
  --color-text-muted: var(--slate-600);
  --color-action: var(--blue-600);
}

/* 3. Component */
.button {
  --button-bg: var(--color-action);
  background: var(--button-bg);
  color: var(--white);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-md);
}

Naming design tokens that people understand

  • Use a consistent pattern, such as --{category}-{role}-{variant}: --color-text-muted, --color-border-strong, --space-4.
  • Name semantic tokens by purpose, not appearance. --color-danger survives a redesign; --color-red does not.
  • Use a numeric scale for spacing and sizes so people can guess the next step. Many teams base spacing on 4px or 8px steps.
  • Mirror Figma. Figma variable names use slashes to create groups, such as color/text/muted. Swap slashes for hyphens in CSS and both sides read the same.
  • Document the list in one place – a page in your Figma file and a comment block at the top of tokens.css.

From Figma Variables to CSS

Figma Variables are organised into collections, and a collection can have several modes – for example Light and Dark, or Compact and Comfortable. That structure maps neatly to CSS:

In FigmaIn CSS
A primitive collection, such as PaletteCustom properties on :root, such as --blue-600
A semantic collection that aliases primitivesProperties that use var(), such as --color-link: var(--blue-600)
Modes (Light, Dark)A selector per mode, such as [data-theme="dark"], overriding the semantic properties
Number variables for spacing and radiusLength tokens, such as --space-4: 1rem and --radius-md: 8px
Variable names grouped with slashesHyphenated names in the same order

In Dev Mode, developers can see which variable a design uses rather than just a raw hex value, so matching names on both sides saves a lot of back-and-forth. Agree one rule early: if a value is not a token in Figma, it should not be a raw value in code either, and vice versa. See Figma Auto Layout Guide for how spacing tokens and auto layout work together, and Design Handoff to Developers for the wider handoff process.

Theming and dark mode

Because semantic tokens are just custom properties, a theme is a block that overrides them. Here are two common approaches, which you can combine: follow the system setting by default, and let users override it.

/* Follow the operating system */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg: var(--slate-900);
    --color-text: var(--slate-100);
    --color-action: var(--blue-300);
  }
}

/* User chose dark in your site's toggle */
:root[data-theme="dark"] {
  --color-bg: var(--slate-900);
  --color-text: var(--slate-100);
  --color-action: var(--blue-300);
}

There is also the light-dark() function, Baseline since May 2024. It picks the first value in a light colour scheme and the second in a dark one, but it only works when color-scheme is set:

:root {
  color-scheme: light dark;
  --color-bg: light-dark(#ffffff, #0f172a);
  --color-text: light-dark(#0f172a, #f1f5f9);
}

For more on choosing dark palettes – not just inverting colours – read Dark Mode Design and CSS and Colour Theory for UI Designers.

Try it: tokens, themes and a brand switch

This demo uses primitive, semantic and component tokens. The buttons switch the theme with a data-theme attribute and change the brand colour from JavaScript with setProperty. No component CSS changes at all.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Design tokens demo</title>
<style>
  :root {
    --blue-600: #1d4ed8; --blue-300: #93c5fd;
    --slate-900: #0f172a; --slate-800: #1e293b; --slate-600: #475569; --slate-300: #cbd5e1; --slate-100: #f1f5f9;
    --white: #ffffff;
    --space-2: 0.5rem; --space-4: 1rem; --space-6: 1.5rem;
    --radius-md: 10px;

    --brand: var(--blue-600);
    --color-bg: var(--slate-100);
    --color-surface: var(--white);
    --color-text: var(--slate-900);
    --color-text-muted: var(--slate-600);
    --color-border: var(--slate-300);
    --color-action: var(--brand);
    --color-on-action: var(--white);
  }
  [data-theme="dark"] {
    --color-bg: var(--slate-900);
    --color-surface: var(--slate-800);
    --color-text: var(--slate-100);
    --color-text-muted: var(--slate-300);
    --color-border: var(--slate-600);
  }
  body { margin: 0; font-family: system-ui, sans-serif; background: var(--color-bg); color: var(--color-text); padding: var(--space-6); transition: background .2s, color .2s; }
  .card {
    --card-padding: var(--space-6);
    background: var(--color-surface); border: 1px solid var(--color-border);
    border-radius: var(--radius-md); padding: var(--card-padding); max-width: 380px;
  }
  .card.compact { --card-padding: var(--space-4); }
  .muted { color: var(--color-text-muted); }
  .button { background: var(--color-action); color: var(--color-on-action); border: 0; border-radius: var(--radius-md); padding: var(--space-2) var(--space-4); font: inherit; cursor: pointer; }
  .controls { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-bottom: var(--space-4); }
  .controls button { font: inherit; padding: 6px 10px; }
</style>
</head>
<body>
  <div class="controls">
    <button type="button" id="theme">Toggle dark theme</button>
    <button type="button" data-brand="#1d4ed8">Blue brand</button>
    <button type="button" data-brand="#0f766e">Teal brand</button>
    <button type="button" data-brand="#b45309">Saffron brand</button>
  </div>
  <div class="card">
    <h2>Auroville cycle tour</h2>
    <p class="muted">Half-day ride from Puducherry, 18 km, breakfast included.</p>
    <button class="button" type="button">Reserve a seat</button>
  </div>
  <p id="out" class="muted"></p>
<script>
  const root = document.documentElement;
  const out = document.getElementById('out');
  function report() {
    const brand = getComputedStyle(root).getPropertyValue('--brand').trim();
    out.textContent = 'Theme: ' + (root.dataset.theme || 'light') + ' | --brand: ' + brand;
  }
  document.getElementById('theme').addEventListener('click', () => {
    root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
    report();
  });
  document.querySelectorAll('[data-brand]').forEach(btn => {
    btn.addEventListener('click', () => {
      root.style.setProperty('--brand', btn.dataset.brand);
      report();
    });
  });
  report();
</script>
</body>
</html>

Notice the .card.compact rule: it overrides only --card-padding, a component token, instead of rewriting the padding property. Component tokens make variants cheap.

Scoping tokens to one section

Tokens do not have to be global. Because custom properties inherit, you can redefine a semantic token on any element and everything inside follows. This is useful for a dark promotional banner on an otherwise light page, or for a sidebar with a different accent colour:

.promo {
  --color-bg: var(--slate-900);
  --color-text: var(--slate-100);
  --color-action: var(--blue-300);
  background: var(--color-bg);
  color: var(--color-text);
}

/* Buttons inside .promo pick up the new --color-action automatically */

The same button component now looks right in both places without a .promo .button override. This is one of the biggest practical wins of building with tokens: components stay simple, and context decides the values.

Typed tokens with @property

Normally the browser treats a custom property’s value as an untyped string, which is why you cannot smoothly animate one – it does not know --angle: 0deg is an angle. The @property rule, Baseline since July 2024, registers a type, an initial value and whether it inherits:

@property --progress {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

.progress-bar {
  background: linear-gradient(to right, var(--color-action) var(--progress), var(--color-border) 0);
  transition: --progress 0.4s ease;
}

Registered properties can be transitioned, and the browser checks their values against the declared type instead of treating them as plain text. Use them for tokens you animate or want type-checked; ordinary tokens do not need registering.

Gotchas to know

  • Not in media queries. @media (min-width: var(--bp-md)) does not work. Keep breakpoints as plain values or use a build tool. See Responsive Design Breakpoints.
  • Invalid at computed-value time. If a variable holds something that does not fit the property, such as --space: red used in padding, the property silently falls back to its inherited or initial value. DevTools shows the computed value – check there.
  • Names are case-sensitive. --Brand and --brand are different properties.
  • Units do not concatenate. var(--size)px does not work. Store the unit in the token, or use calc(var(--size) * 1px).
  • Too many tokens is a problem too. If every element gets its own token, you have moved the mess, not removed it. Start with colour, spacing, radius and type scale.

Design tokens in frameworks

You will see this pattern in modern frameworks. Bootstrap 5.3 exposes its theme as --bs- custom properties and switches colour modes with data-bs-theme – see Customising Bootstrap and dark mode. Tailwind CSS v4 defines tokens in an @theme block and outputs them as CSS variables, compared in Bootstrap vs Tailwind CSS. Learning design tokens in plain CSS makes both easier to customise.

A rollout checklist

  1. List the colours, spacing steps, radii, shadows and type sizes actually used in your designs. Merge near-duplicates.
  2. Create primitives, then semantic roles. Agree names with the designer and match Figma Variables.
  3. Put tokens in one file, such as tokens.css, loaded before everything else.
  4. Replace hard-coded values in components with semantic tokens, one component at a time.
  5. Add a dark theme by overriding semantic tokens only, then check contrast with WCAG in mind.
  6. Review new code for raw hex values and magic numbers.

Frequently asked questions

What is the difference between CSS variables and Sass variables?

Sass variables are replaced with fixed values when the CSS is compiled. CSS custom properties exist in the browser, inherit, follow the cascade and can be changed at runtime by classes, media queries or JavaScript.

What are design tokens?

Design tokens are named design decisions such as colours, spacing, radii and font sizes, stored in one place so design and code use the same values. CSS custom properties are a common way to implement them on the web.

Can I use CSS variables in media queries?

No. var() is not allowed inside media query conditions. Use fixed values for breakpoints, or generate them with a build tool.

How do I change a CSS variable with JavaScript?

Use element.style.setProperty(‘–name’, value) to set it and getComputedStyle(element).getPropertyValue(‘–name’) to read it. Setting it on document.documentElement affects the whole page.

Do I need @property for every token?

No. Register a property only when you need to animate it, enforce a type or control inheritance. Most colour and spacing tokens work fine without it.

Further reading

Keep reading

All articles