Design tokens
A token is a named design decision: a color, a spacing, a radius, a font size, a duration. The design system defines the tokens as CSS custom properties, and every component uses them instead of raw values. A brand change, a dark theme or a density change is then a change of tokens, not of hundreds of components.
Three levels
| Level | Named after | Example | Used by |
|---|---|---|---|
| Primitive | what the value is | --color-blue-500, --space-300 | semantic tokens only |
| Semantic | what the value is for | --color-action, --color-text-muted, --space-inline | components |
| Component (optional) | a part of one component | --button-padding | that component, to let a parent tune it |
Components use semantic tokens: --color-action says why the button is blue, and a theme can change it without touching the button.
Primitives: the palette and the scales.
// Primitive tokens: the raw palette and scales, named after what they are. Components never use them directly.
@layer tokens {
:root {
--color-blue-100: oklch(95% 0.03 250deg);
--color-blue-500: oklch(58% 0.16 250deg);
--color-blue-700: oklch(45% 0.15 250deg);
--color-gray-0: oklch(100% 0 0deg);
--color-gray-100: oklch(96% 0 0deg);
--color-gray-700: oklch(38% 0 0deg);
--color-gray-900: oklch(20% 0 0deg);
--color-red-600: oklch(55% 0.2 27deg);
--font-family-sans: system-ui, sans-serif;
--font-size-100: 14px;
--font-size-200: 1rem;
--font-size-300: 20px;
--radius-100: 4px;
--radius-200: 8px;
--space-100: 4px;
--space-200: 8px;
--space-300: 1rem;
--space-400: 24px;
}
}Semantic tokens, and a dark theme that only changes them:
// Semantic tokens: what a value is for. Components use these; a theme only changes this file.
@layer tokens {
:root {
--color-action: var(--color-blue-500);
--color-action-hover: var(--color-blue-700);
--color-action-text: var(--color-gray-0);
--color-danger: var(--color-red-600);
--color-surface: var(--color-gray-0);
--color-surface-muted: var(--color-gray-100);
--color-text: var(--color-gray-900);
--color-text-muted: var(--color-gray-700);
--font-size-body: var(--font-size-200);
--font-size-small: var(--font-size-100);
--font-size-title: var(--font-size-300);
--radius-control: var(--radius-100);
--radius-surface: var(--radius-200);
--space-inline: var(--space-200);
--space-stack: var(--space-300);
}
// Dark theme: only the semantic tokens change.
[data-theme='dark'] {
--color-surface: var(--color-gray-900);
--color-surface-muted: var(--color-gray-700);
--color-text: var(--color-gray-0);
--color-text-muted: var(--color-gray-100);
}
}Naming
--<category>-<name>, kebab-case: --color-text-muted, --space-inline, --radius-control, --font-size-title, --z-overlay, --duration-fast. Stylelint checks that custom properties are kebab-case (custom-property-pattern, a warning). Sizes in rem are whole steps (1rem, 2rem); the small ones are in px (4px, 8px): a 0.25rem is a warning.
- Scales use steps with room between them (
100,200,300…) so that a value can be inserted later. - Colors in
oklch(): perceptually uniform, so--color-blue-500and--color-red-500have the same lightness, and derived shades are predictable. - No value in a name (
--space-16px): the value may change, the name stays.
Use them
.button {
padding-block: var(--space-inline);
color: var(--color-action-text);
background: var(--color-action);
border-radius: var(--radius-control);
}A raw color, spacing, radius, shadow, font or z-index belongs in the token files only. Stylelint warns about a named color (red); the review checks the raw values, so that a forgotten #3366cc never reaches main.
Component tokens: an API for parents
A component can expose a few custom properties with a default token, so that a parent adjusts it without overriding its internals:
:host {
--button-padding: var(--space-inline); // the public knob, defaults to a token
}
.button {
padding: var(--button-padding);
}.toolbar ds-button {
--button-padding: var(--space-100); // a denser button, without touching the button's styles
}Themes
A theme is a set of semantic tokens under a selector ([data-theme='dark'], or @media (prefers-color-scheme: dark) to follow the system). Primitives never change: only the mapping from purpose to value does. Switch with one attribute on <html>:
document.documentElement.dataset['theme'] = 'dark';Animatable tokens
A custom property is animated as a string (it jumps). Register it with @property to interpolate it:
@property --progress {
syntax: '<percentage>';
initial-value: 0%;
inherits: false;
}