Design Tokens with CSS Variables: Build Consistent, Flexible Themes

Design tokens let you store visual decisions such as colors, spacing, font sizes, and shadows in one place and reuse them across your stylesheets. In CSS, custom properties make this possible with a simple, scalable pattern that helps you keep a consistent design system and update it safely.

Quick answer: CSS design tokens are usually implemented with custom properties such as --color-primary and read with var(). Define tokens once, then reuse them everywhere so design changes are easier and less error-prone.

Difficulty: Beginner

You'll understand this better if you know: basic CSS selectors, how the cascade works, and how values are assigned to properties.

1. What Are Design Tokens with CSS Variables?

Design tokens are named values that represent reusable design decisions. Instead of repeating raw values like #2563eb or 16px throughout your CSS, you give those values meaningful names and reuse them through CSS custom properties.

In practice, a token like --color-primary becomes a shared source of truth for the rest of your CSS.

2. Why Design Tokens Matter

Design tokens reduce repetition and make large stylesheets easier to maintain. When a brand color changes, you update one token instead of searching through many rules. When spacing needs to become more compact, you can adjust a few spacing tokens rather than rewriting entire components.

They are especially useful in projects where many pages or components must look consistent:

They matter less for one-off demo pages, but even small sites benefit from defining core values once instead of scattering hard-coded numbers everywhere.

3. Basic Syntax or Core Idea

CSS custom properties use a name that starts with --. You usually define them on :root so they are available across the page, then read them with var().

Defining a token

This example shows the smallest useful pattern for a color token and a spacing token.

:root {
  --color-primary: #2563eb;
  --space-3: 1rem;
}

button {
  background-color: var(--color-primary);
  padding: var(--space-3);
}

The custom property stores the value, and var() inserts that value wherever it is used. This is the foundation of token-based CSS.

Token names are just CSS identifiers

The name after -- is your own naming system. Good names describe meaning, not just appearance, so --color-primary is better than --blue-500 when the value may change later.

4. Step-by-Step Examples

Example 1: Building a button token set

Here, several tokens define the button’s color, padding, radius, and shadow. This keeps the component readable and easy to tune later.

:root {
  --color-primary: #0f766e;
  --color-on-primary: #ffffff;
  --button-padding: 0.75rem 1rem;
  --radius-md: 0.5rem;
  --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.12);
}

.button {
  background: var(--color-primary);
  color: var(--color-on-primary);
  padding: var(--button-padding);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
}

This pattern is common in component styling because every value comes from a named token instead of a magic number.

Example 2: Adding a dark theme override

Tokens become even more useful when you override them in a specific context. The component CSS stays the same, but the values change.

:root {
  --surface: #ffffff;
  --text: #111827;
}

.theme-dark {
  --surface: #111827;
  --text: #f9fafb;
}

body {
  background-color: var(--surface);
  color: var(--text);
}

Applying .theme-dark to a wrapper changes the visual system without rewriting each component rule.

Example 3: Using component-level tokens

Sometimes a component needs local values that still fit the token pattern. You can define component-specific tokens and use them only inside that component.

.card {
  --card-padding: 1.25rem;
  --card-gap: 0.75rem;
  --card-border: 1px solid rgb(0 0 0 / 0.08);

  padding: var(--card-padding);
  border: var(--card-border);
}

.card h2 {
  margin-bottom: var(--card-gap);
}

Local tokens are useful when a component has internal spacing rules that should be easy to adjust together.

Example 4: Using token fallbacks

var() can include a fallback value if a token is missing. This is helpful when you want a safe default.

.banner {
  background-color: var(--banner-bg, #f3f4f6);
  color: var(--banner-text, #111827);
}

If a token is not defined in the current scope, the fallback keeps the rule valid and prevents the value from becoming unusable.

5. Practical Use Cases

These use cases are strongest when visual consistency matters across many files or many contributors.

6. Common Mistakes

Mistake 1: Defining a token after it is used in the same rule

Custom properties are resolved using the cascade and scope. If a value is missing at the point where it is needed, the browser cannot apply it as expected.

Problem: The rule below tries to use a token before it has a valid value in scope, so the browser falls back to the fallback or treats the declaration as invalid depending on the situation.

.button {
  background-color: var(--button-bg);
  --button-bg: #2563eb;
}

Fix: Define the token first, or place it on a parent scope such as :root or the component itself before the property that uses it.

:root {
  --button-bg: #2563eb;
}

.button {
  background-color: var(--button-bg);
}

This works because the token is available when the browser resolves the value.

Mistake 2: Treating custom properties like preprocessor variables

CSS variables are resolved by the browser at runtime, not compiled away beforehand. That means they follow the cascade, inheritance, and scope rules of CSS.

Problem: Beginners often expect a token to behave like a build-time constant and are surprised when it changes inside a nested theme or component.

:root {
  --text-color: #111827;
}

.dark-panel {
  --text-color: #f9fafb;
}

.title {
  color: var(--text-color);
}

Fix: Use the cascade intentionally. If a value should change in a section of the page, override it there; if it should stay global, keep it on :root.

:root {
  --text-color: #111827;
}

.dark-panel {
  --text-color: #f9fafb;
}

.title {
  color: var(--text-color);
}

The corrected version works because it uses inheritance as the feature, not as a surprise.

Mistake 3: Forgetting a fallback for optional tokens

Some tokens are intentionally optional, such as a component-specific background or accent. If the token is missing, the entire declaration can become invalid.

Problem: If --card-accent is not defined anywhere, the browser cannot use the value and the style may disappear.

.card {
  border-left: 4px solid var(--card-accent);
}

Fix: Add a fallback so the card still renders even when no accent token is provided.

.card {
  border-left: 4px solid var(--card-accent, #cbd5e1);
}

This works because the browser always has a safe value to use when the token is absent.

7. Best Practices

Practice 1: Name tokens by purpose, not just by color

Purpose-based names keep your system flexible. If you call a token --color-primary, you can change its hue later without renaming every usage.

:root {
  --color-primary: #7c3aed;
}

A name like this describes its role in the system instead of locking you into a specific color family.

Practice 2: Keep a small, intentional scale

Too many tokens create confusion. A short spacing scale and a small set of semantic colors are easier to understand and maintain than dozens of nearly identical values.

:root {
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 1rem;
}

A compact scale encourages consistency and makes later changes safer.

Practice 3: Separate raw values from semantic tokens

Use raw scale tokens for foundational values and semantic tokens for meaning. For example, a raw palette token can feed a semantic brand token.

:root {
  --blue-600: #2563eb;
  --color-primary: var(--blue-600);
}

This layering makes design changes easier because a brand token can be remapped without touching every component.

8. Limitations and Edge Cases

A common "not working" complaint is that a token appears set, but a component still looks unchanged. In many cases, the real issue is scope: the component is not inside the element where the token is overridden, or another rule with higher priority is winning.

9. Practical Mini Project

Let’s build a small profile card that uses a token system for color, spacing, and radius. The result is a reusable card that can be themed by changing only the tokens.

:root {
  --color-surface: #ffffff;
  --color-text: #1f2937;
  --color-accent: #2563eb;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --radius-md: 0.75rem;
}

.profile-card {
  background-color: var(--color-surface);
  color: var(--color-text);
  padding: var(--space-4);
  border-radius: var(--radius-md);
  border-left: 4px solid var(--color-accent);
}

.profile-card h2 {
  margin-bottom: var(--space-2);
}

.profile-card p {
  margin-top: 0;
  margin-bottom: var(--space-3);
}

This example shows the token pattern in a realistic component: one set of values controls the whole card, and changing a token updates every use of that value.

10. Key Points

11. Practice Exercise

Expected output: the same card layout should appear in both themes, but the colors should change when the theme class is applied.

Hint: Put your shared values on :root, then override them inside a wrapper class such as .theme-alt.

Solution:

:root {
  --surface: #ffffff;
  --text: #111827;
  --accent: #dc2626;
  --card-padding: 1rem;
  --card-radius: 0.75rem;
}

.theme-alt {
  --surface: #0f172a;
  --text: #e2e8f0;
  --accent: #38bdf8;
}

.card {
  background-color: var(--surface);
  color: var(--text);
  padding: var(--card-padding);
  border-radius: var(--card-radius);
  border: 1px solid var(--accent);
}

.card h2 {
  margin-top: 0;
}

.card a {
  color: var(--accent);
}

This solution works because the component reads only tokens, while the theme wrapper changes the underlying values.

12. Final Summary

CSS design tokens give your styles a shared vocabulary for colors, spacing, typography, and other visual decisions. By using CSS custom properties, you can define values once and reuse them across components, pages, and themes without repeating raw numbers everywhere.

The most important habits are to choose meaningful token names, keep your scales small and purposeful, and use the cascade deliberately for theme overrides. When you combine those habits with var() fallbacks and clear scope boundaries, your CSS becomes easier to maintain and much more adaptable.

As a next step, try extracting the repeated colors and spacing from one real component in your project and converting them into a tiny token set. That small refactor will show you how much clarity tokens can add to everyday CSS.