Foundations

Design Tokens

How to name the CSS variables behind your components, so that people, tools and agents all read them the same way.

Design Tokens are the named decisions behind every component, such as its colours, sizes, spacing and corner radii. In CSS, they're custom properties, and since their names are all that people, tools and agents have to go on, the names matter as much as the values.

The biggest problem is that a name like --blue-2 doesn't say what it's for, and --color-primary-text can be read in more than one way. Is it the primary-text colour, or the text of color-primary?

This page covers how every Open Components token is named, a convention we call token paths. We'll look at:

  • Token paths - how to name a token, and why with a double dash
  • Semantic names - how to name a token after what it's for
  • Theme tokens - the colours that every component shares
  • Component variables - how a component names its own variables
  • Checking tokens - how linters and agents can hold your tokens to all of this

At a glance

AspectTokens
ConventionToken paths, as in --{group}--{name}
Separators-- between groups, and - between the words inside a group or a name
CharactersLowercase letters and digits
NamesSemantic, saying what a token is for rather than what it looks like
Theme--color--{color}, --color--{color}-contrast, --color--{color}-text and --color--focus
Components--{component}--{name}, or --{component}--{part}--{name} for a part, set on the component's root
Tools{color.primary} in DTCG, color/primary in Figma and color.primary in Styleframe
LintStylelint's custom-property-pattern

Token paths

A token's name is its path, made of the groups it belongs to, followed by its own name. Groups are separated by a double dash, and the words inside a group or a name by a single dash.

:root {
  --color--primary: …;          /* color › primary */
  --color--primary-contrast: …; /* color › primary-contrast */
  --font-size--sm: …;           /* font-size › sm */
}

.button {
  --button--icon--size: 1rem;   /* button › icon › size */
}

We call this convention token paths because the double dash plays the part of the dot in a path. The path stays the same wherever your tokens live, so moving between your design files, your token files and your CSS only means swapping the separator:

WhereHow it's written
CSS--color--primary-text
Design tokens (DTCG){color.primary-text}
Figma variablescolor/primary-text
Styleframecolor.primary-text

Groups

Most tokens only need two levels, the first saying what they belong to, and the second what they are.

  • The first group is the token's scope. It's either a category, like color, spacing, font-size or border-radius, or a component, like button.
  • The last part is the token's name, like primary, md or height.
  • A variation of a token stays in its name. The label on the primary fill is --color--primary-contrast, and a darker shade would be --color--primary-shade-50, rather than --color--primary--contrast or --color--primary--shade-50.

When a token belongs to a part or a state of a component, rather than to the component as a whole, give that part or state a group of its own, even for a single token. The button sizes its icons with --button--icon--size, a tooltip's arrow would have --tooltip--arrow--size, and a link's hover state --link--hover--color. Since the group is there from the first token, adding a second one later, like --button--icon--color, never means renaming the first.

A CSS property stays in one piece, though. font-size is a single name, so the button's text is sized with --button--font-size, rather than --button--font--size, and the same goes for names like border-radius and line-height.

Why a double dash

The double dash is what makes a name readable in both directions, by people and by tools.

  • It's unambiguous. With single dashes, --font-size-sm could be font.size-sm, font-size.sm or font.size.sm, and a tool has no way to tell which. --font-size--sm can only be font-size.sm, so tools, and agents, can turn any name back into its path. It's the same reason the DTCG format doesn't allow dots in token names.
  • It's searchable. Searching for --button-- finds every variable of the button and nothing else, while --button- would also find a button group's --button-group--gap.
  • It doesn't clash. Tailwind CSS names its theme variables with single dashes, like --color-blue-500 and --radius-lg, and libraries built on it add their own, like Nuxt UI's --color-primary. A token path can never have the same name as one of them, so your tokens and theirs can share a page.

Semantic names

Name a token after what it's for, rather than what it looks like. --color--primary still makes sense after a rebrand and in dark mode, while --color--indigo-600 doesn't. It's the same idea as the Button's color prop, which describes an intent rather than a hue.

Tokens come in up to three tiers, and each tier reads from the one before it:

TierWhat it holdsFor exampleRead by
PaletteEvery value you have--color--indigo-600, --spacing--4The theme
ThemeWhat each value is for--color--primary, --color--focusComponents
ComponentOne component's own decisions--button--height, --button--fillThat component

The palette is optional. Our reference tokens skip it, and set the theme straight from values in the Tailwind CSS palette. What matters is that components only read theme tokens, so a new brand or a dark mode is a new set of theme values, and no component has to change.

/* Do: the button reads what the colour is for */
.button[data-color="primary"] {
  --button--fill: var(--color--primary);
}

/* Don't: it reads a colour from the palette, so a new brand means editing every component */
.button[data-color="primary"] {
  --button--fill: var(--color--indigo-600);
}

Theme tokens

Every component reads its colours from the same theme tokens, so a single theme styles them all. There are three tokens per colour, plus a focus colour:

TokenWhat it is
--color--{color}The colour itself, for fills and tints
--color--{color}-contrastText and icons on top of the fill
--color--{color}-textText and icons in that colour, straight on the page
--color--focusThe focus ring, which is the same for every component

{color} is one of primary, secondary, neutral, success, info, warning and error, the same colours as the color prop. A component with color="primary" reads --color--primary, --color--primary-contrast and --color--primary-text.

Each pairing has to stay readable. -contrast needs a contrast of 4.5:1 against its colour, -text needs 4.5:1 against the page (WCAG 1.4.3), and --color--focus needs 3:1 against the page (WCAG 1.4.11), in both light and dark mode. Components that mix these colours, like the Button's soft variant, check their own pairings on top of that.

The tokens use light-dark(), which follows color-scheme, so a single token holds the value for both modes:

:root {
  --color--primary: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935));
  --color--primary-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823));
  --color--primary-text: light-dark(oklch(45.7% 0.24 277.023), oklch(78.5% 0.115 274.713));
  /* …secondary, neutral, success, info, warning and error */
  --color--focus: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935));
}

You'll find the full set, with values that pass in both modes, in the Button's tokens.css.

Component variables

A component names its own variables after itself, as --{component}--{name}, or --{component}--{part}--{name} for one of its parts, and sets them on its root:

.button {
  --button--height: 2.25rem;
  --button--radius: 0.5rem;
  --button--icon--size: 1rem;
  --button--fill: var(--color--neutral);
}
.button[data-size="sm"] {
  --button--height: 2rem;
  --button--radius: 0.375rem;
}
.button[data-color="primary"] {
  --button--fill: var(--color--primary);
}
  • Setting them on the root, rather than on :root, keeps them with the component. Each size or colour changes them with a single selector, and nothing leaks into the rest of the page.
  • Starting them with the component's name makes it clear who owns them, so --button--fill can never be mistaken for a theme token.
  • Their colours come from the theme tokens, so the component follows every theme on its own.

To tune a component, set its variables from a class of yours rather than with an inline style, since inline styles can't be reused, can't respond to media queries and are blocked by a strict Content Security Policy. Components put their styles in the components cascade layer, and a style outside a layer beats a layered one whatever its specificity, so a single class is all it takes:

GetStarted.tsx
import styles from "./GetStarted.module.css";

export function GetStarted() {
  return (
    <Button color="primary" className={styles.pill}>
      Get started
    </Button>
  );
}
GetStarted.module.css
.pill {
  --button--radius: 999px;
}

The layers follow the same order as Tailwind CSS (theme, base, components and utilities), so components win over its resets and give way to its utilities. Keep in mind that a reset of your own outside a layer, like button { border-radius: 0 }, would win too, so it's best to put your resets in the base layer.

Every component page covers its own variables under Tokens, as the Button does.

Checking tokens

The naming can be checked automatically, and agents can check the rest against the contract below.

Lint

Stylelint's custom-property-pattern rule checks every custom property against a pattern, without its leading --. This one accepts token paths and nothing else:

.stylelintrc.json
{
  "rules": {
    "custom-property-pattern": [
      "^[a-z0-9]+(-[a-z0-9]+)*(--[a-z0-9]+(-[a-z0-9]+)*)+$",
      { "message": "Name CSS variables as token paths, like --color--primary" }
    ]
  }
}

It flags --color-primary, --button-height, --Color--primary, --color---primary and --primary, and it checks var() references as well as declarations. That means it flags other libraries' variables too, like Tailwind's, so run it on your theme and your components' styles.

Described

Here's the whole convention in one block, for agents to read before they write or review a token:

convention: token-paths
standard: https://opencomponents.dev/docs/foundations/design-tokens
format: "--{group}--{name}" # "--{group}--{part or state}--{name}" for a part or state, even with one token
separators: { group: "--", word: "-" }
pattern: "^--[a-z0-9]+(-[a-z0-9]+)*(--[a-z0-9]+(-[a-z0-9]+)*)+$"
paths:
  css: "--color--primary"
  dtcg: "{color.primary}"
  figma: color/primary
  styleframe: color.primary
theme:
  colors: [primary, secondary, neutral, success, info, warning, error]
  tokens: ["--color--{color}", "--color--{color}-contrast", "--color--{color}-text", "--color--focus"]
components: "--{component}--{name}, or --{component}--{part}--{name}, set on the component's root"
overrides: "A class of yours, outside a layer. Components style themselves in @layer components, in Tailwind's layer order."
rules: https://opencomponents.dev/docs/foundations/design-tokens#checklist

It's also published on its own at /raw/docs/foundations/design-tokens.yaml, with every rule from the checklist in place of the rules link.

Prompts

You can point your agent straight at this page. To move your variables over to token paths, you could use:

Rename our CSS variables to follow the Open Components token paths:
https://opencomponents.dev/raw/docs/foundations/design-tokens.md

List our token groups first, then rename every variable along with every var() that
reads it, keeping the values as they are. Then add the Stylelint rule from the page,
and fix anything it reports.

And to review them:

Review our theme and component styles against the Open Components tokens standard:
https://opencomponents.dev/raw/docs/foundations/design-tokens.md

For each rule in its checklist, report pass or fail with the rule ID and the evidence
from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with how to check it. Your tokens meet the standard when they meet every must, and each should is expected unless you have a good reason not to follow it.

RuleLevelRequirementCheck
tokens/token-pathsMustVariables are named as token paths, in lowercase, with - between words and -- between groupsStylelint
tokens/semantic-namesMustTheme and component tokens say what they're for, rather than what they look likeReview
tokens/theme-onlyMustComponents read theme tokens, and never palette tokens or raw coloursReview
tokens/shared-themeMustComponents read the shared theme tokens, so a single theme styles them allReview
tokens/component-scopeMustA component's own variables start with its name, like --button--, and are set on its rootReview
tokens/class-overridesShouldComponents put their styles in the components cascade layer, so a class can set their variables without an inline styleReview
tokens/contrastMust-contrast has 4.5:1 against its colour, -text 4.5:1 against the page and --color--focus 3:1, in light and dark modeContrast checker
tokens/groupsShouldA part or state always gets its own group, like --button--icon--size, while a variation or a CSS property stays in one nameReview

FAQ

Sources

Copyright © 2026