Design Tokens
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
| Aspect | Tokens |
|---|---|
| Convention | Token paths, as in --{group}--{name} |
| Separators | -- between groups, and - between the words inside a group or a name |
| Characters | Lowercase letters and digits |
| Names | Semantic, 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 |
| Lint | Stylelint'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:
| Where | How it's written |
|---|---|
| CSS | --color--primary-text |
| Design tokens (DTCG) | {color.primary-text} |
| Figma variables | color/primary-text |
| Styleframe | color.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-sizeorborder-radius, or a component, likebutton. - The last part is the token's name, like
primary,mdorheight. - 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--contrastor--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-smcould befont.size-sm,font-size.smorfont.size.sm, and a tool has no way to tell which.--font-size--smcan only befont-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-500and--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:
| Tier | What it holds | For example | Read by |
|---|---|---|---|
| Palette | Every value you have | --color--indigo-600, --spacing--4 | The theme |
| Theme | What each value is for | --color--primary, --color--focus | Components |
| Component | One component's own decisions | --button--height, --button--fill | That 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:
| Token | What it is |
|---|---|
--color--{color} | The colour itself, for fills and tints |
--color--{color}-contrast | Text and icons on top of the fill |
--color--{color}-text | Text and icons in that colour, straight on the page |
--color--focus | The 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--fillcan 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:
import styles from "./GetStarted.module.css";
export function GetStarted() {
return (
<Button color="primary" className={styles.pill}>
Get started
</Button>
);
}
.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:
{
"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.
| Rule | Level | Requirement | Check |
|---|---|---|---|
tokens/token-paths | Must | Variables are named as token paths, in lowercase, with - between words and -- between groups | Stylelint |
tokens/semantic-names | Must | Theme and component tokens say what they're for, rather than what they look like | Review |
tokens/theme-only | Must | Components read theme tokens, and never palette tokens or raw colours | Review |
tokens/shared-theme | Must | Components read the shared theme tokens, so a single theme styles them all | Review |
tokens/component-scope | Must | A component's own variables start with its name, like --button--, and are set on its root | Review |
tokens/class-overrides | Should | Components put their styles in the components cascade layer, so a class can set their variables without an inline style | Review |
tokens/contrast | Must | -contrast has 4.5:1 against its colour, -text 4.5:1 against the page and --color--focus 3:1, in light and dark mode | Contrast checker |
tokens/groups | Should | A part or state always gets its own group, like --button--icon--size, while a variation or a CSS property stays in one name | Review |
FAQ
-- marks a modifier, as in .button--primary. Token paths use it the way a path uses a dot, so --color--primary reads as the primary token in the color group, rather than a colour modified to be primary. BEM names classes and token paths name variables, so the two can live side by side.Yes. Tailwind reads any variable you put in parentheses, as in bg-(--color--primary). If you'd like shorter utilities, you can map your tokens onto its theme:
@theme inline {
--color-primary: var(--color--primary);
}
With that, bg-primary sets background-color: var(--color--primary), so your tokens stay the single source of truth.
--color--warning is a bright yellow, so the text on top of it has to be dark, and on a white page, warning text needs a much darker shade than the fill. With -contrast and -text, each pairing gets a colour that passes on its own, in both light and dark mode.One group at a time. A script can't tell where a single-dash name's group ends, which is exactly the problem token paths solve, so start by listing your groups, like color and button. Then replace --color- with --color--, and so on, in declarations and var() references alike. With Perl, for example:
perl -pi -e 's/--(color|button)-(?=[a-z])/--$1--/g' $(git ls-files '*.css' '*.vue')
The Stylelint rule above will then catch anything you've missed.
Sources
- Design Tokens Format Module, for token paths and groups
- CSS Custom Properties for Cascading Variables
- Styleframe: Variables, for the dot notation and its double-dash output
- Figma: Overview of variables, collections and modes
- Material Design 3: Design tokens, for the tiers
- Tailwind CSS: Theme variables
- Stylelint: custom-property-pattern
- Web Content Accessibility Guidelines (WCAG) 2.2