Naming & tokens
The naming system is the contract that lets a designer in Figma and an engineer in CSS mean the same thing when they say “subtle border”. Tokens outlive components. Renames are expensive. This page fixes the three tiers, the single grammar every name follows, and the lint rules that keep the tiers honest.
| Tiers | 3 – primitive, semantic, component |
| Grammar | --es-<category>-<role>[-<variant>][-<state>] |
| Casing | kebab-case |
| Prefix | --es- (always) |
| Format | W3C Design Tokens (DTCG) |
| Enforced by | Stylelint, custom tier rule, vocabulary check |
The three tiers
Every token belongs to exactly one tier. A token may only reference a token at the tier directly above it – component asks semantic, semantic asks primitive, primitive holds a literal value. No skipping, no reaching back.
Primitive
The raw value. Scale-indexed or literal – named by what it is, never by where it’s used. Stable identity, no intent.
—es-space-4
—es-radius-md
—es-duration-base
Semantic
Intent-named. Says what role the value plays in the system. Can re-bind to a different primitive without renaming.
—es-color-border-subtle
—es-spacing-stack-md
—es-radius-card
Component
Scoped to one component. Re-skinning a component means rebinding these – never editing the component’s CSS.
—es-field-border-focus
—es-card-padding-lg
The dependency direction is fixed and one-way:
/* component → semantic → primitive */
--es-button-primary-bg: var(--es-color-action-primary-default);
--es-color-action-primary-default: var(--es-color-teal-600);
--es-color-teal-600: #06A0A9;
Mode swaps and re-skins happen in the middle layer. The button doesn’t know. The hex doesn’t move.
Token shape
Every name follows the same five-segment grammar, regardless of tier:
--es-<category>-<role>[-<variant>][-<state>]
Read left to right – category is the largest scope, state the smallest. Optional segments drop out when they aren’t needed. Never insert filler (“default”, “base”, “rest”).
—es-color-navy-900 —es-color-text-primary
—es-space-4
—es-button-primary-bg —es-color-text-primary
—es-spacing-stack-md —es-color-feedback-danger-surface
—es-color-teal-600 —es-color-action-primary-hover
—es-color-text-linkHover Hyphens, not camelCase, because CSS custom properties are case-sensitive and editorial scanning treats hyphens as word breaks. The --es- prefix is non-negotiable – it’s how lint distinguishes our tokens from anyone else’s. The W3C DTCG type system underneath the CSS gives every token a declared $type (color, dimension, duration, cubicBezier, fontFamily, shadow), which is what lets the build emit CSS, JSON, iOS, and Android in lockstep.
Reserved vocabulary
A handful of words have one – and only one – meaning in this system. Casing across other systems is wild (“active” can mean pressed, selected, or current page); ours is locked.
—es-color-surface-page—es-color-surface-raised—es-color-surface-sunken—es-color-surface-mutedA small but important rule: never use light or dark as a colour intent. Those words are reserved for mode (light mode, dark mode). A token called --es-color-bg-light is ambiguous in a system that ships both modes – it could mean “the light variant” or “the colour for light mode”. Use subtle / default / strong for weight, and surface-page / raised / sunken / muted for elevation.
Tier crossings
Three crossings are allowed. Two are forbidden. The lint rule below enforces both lists.
—es-button-primary-bg: var(—es-color-action-primary-default);
—es-color-action-primary-default: var(—es-color-teal-600);
—es-card-padding-lg: var(—es-space-6);
—es-button-primary-bg: var(—es-color-teal-600);
—es-field-border-focus: var(—es-button-primary-bg);
—es-color-action-primary-default: var(—es-button-primary-bg);
Lint rules
Three checks ride in CI. They turn the rules above from a doc into a contract – mechanical, regex-driven, fast.
1 – Token shape (Stylelint)
Every custom property must match the --es- prefix and the segment grammar. Stylelint’s built-in custom-property-pattern rule does the work; the regex below is what ships.
{
"custom-property-pattern": [
"^--es-[a-z]+(-[a-z0-9]+){1,4}$",
{
"message": "Token names follow --es-<category>-<role>[-<variant>][-<state>] (kebab-case, lowercase, --es- prefix)."
}
]
}
Catches: --esButtonBg, --es_color_teal_600, --button-primary-bg (no prefix), --es-Color-Teal-600 (mixed case).
2 – Tier discipline (custom rule)
A token’s allowed referents depend on its tier. The custom rule classifies each token by its category prefix and walks the var() references in its value.
// scripts/lint-tier-discipline.js
const TIERS = {
primitive: /^--es-(color-(teal|navy|mustard|orange|red|lavender|sage|sky|stone)-\d+|space-\d|radius-(none|xs|sm|md|lg|xl|pill|full)|duration-\w+|easing-\w+)$/,
semantic: /^--es-(color-(surface|text|border|action|feedback|editorial)-|spacing-(inline|stack|inset|section)-|radius-(control|card|surface|action|media|circular)$|elevation-|motion-)/,
component: /^--es-(button|field|card|dialog|toast|nav|tab|chip|table|input)-/,
};
// Rule: a component token may not reference a primitive
// when a matching semantic token exists.
// A semantic token may never reference a component token.
Catches: --es-button-primary-bg: var(--es-color-teal-600) (skips semantic); --es-color-text-primary: var(--es-button-primary-bg) (semantic depends on component).
3 – Reserved vocabulary check
The state segment is drawn from a closed list. Any other word in that position fails the build.
// scripts/lint-reserved-vocab.js
const STATES = new Set([
'hover', 'pressed', 'focus', 'selected', 'current',
'disabled', 'readonly', 'error', 'loading',
]);
const FORBIDDEN_INTENT_WORDS = new Set([
'light', 'dark', // reserved for mode
'active', 'inactive', // ambiguous – use selected / current / disabled
'normal', 'base', 'rest', // filler – the absence of a state is the default
'tiny', 'huge', 'jumbo', // not in the size scale
]);
const REQUIRED_SIZE_VOCAB = new Set(['xs', 'sm', 'md', 'lg', 'xl']);
Catches: --es-button-primary-bg-active (use pressed or selected), --es-color-bg-light (reserved for mode), --es-card-padding-tiny (use xs).
Renaming and deprecation
Token names are public API. Semver applies.
| Change | Bump | What ships |
|---|---|---|
| Add a new token | Minor | No consumer breaks. Documented under Added. |
| Adjust a primitive value within tolerance | Patch | e.g. a hex shift < 1 ΔE. Re-run the contrast matrix. |
| Adjust a semantic binding | Minor | Visible to consumers. Documented under Changed. |
| Rename a token | Major | Old name kept as an alias for one minor version, marked @deprecated. Removed at the next major. |
| Remove a token | Major | Must be deprecated for ≥ 1 minor version first. No drive-by removals. |
| Promote a primitive to semantic | Major | Old name stays as an alias; new name lives at the new tier. |
The deprecation lane in CSS is just an alias plus a comment:
/* @deprecated since 1.4 – use --es-color-text-primary. Removal at 2.0. */
--es-text-ink: var(--es-color-text-primary);
Every deprecation lands in the changelog with the version it appeared in, the version it’ll be removed in, and the replacement token. No silent removals, ever.
Usage rules
color: var(—es-color-text-primary);background: var(—es-color-surface-raised);border: 1px solid var(—es-color-border-default);Components ask for intent. The mode swap, the re-skin, the rebrand – all of it routes through the semantic layer without touching component CSS.
color: var(—es-color-ink);background: var(—es-color-paper);border: 1px solid var(—es-color-stone-300);Locks the component to one mode. The semantic layer stops mattering. The first dark-mode pass becomes a search-and-replace across every component file.
/* Colour ramp swatches */background: var(--es-color-teal-600);The colour, spacing, and motion documentation pages bind directly to primitives because that’s their subject. Product UI never does.
—es-text-navy—es-border-2px—es-bg-redThe first rebrand renames every consumer. Worse: it tempts an engineer to use the wrong token because the colour matches.
—es-color-surface-page—es-button-primary-bgConcise. The grammar absorbs missing variants and states without filler. The absence of a state segment is the default.
—es-color-surface-page-default—es-button-primary-bg-rest-base“Default”, “base”, “normal”, “rest” are noise. They lengthen names without adding meaning, and they invite drift – is bg-default different from bg-rest?
tokens/color.tokens.json ↓ build tokens/color.css ↓ consume src/components/**/*.css
Every CSS variable, every Figma variable, every Swift / Kotlin constant is generated from the same DTCG JSON. CSS is downstream, not the source.
/* Generated — do not edit by hand */--es-color-teal-600: #0AB0BA; /* tweaked */The next build wipes the change. Edit the JSON; rebuild the CSS. Anything else creates a token that’s true in one platform and stale in the others.
/* @deprecated since 1.4 – use --es-color-text-primary */ --es-text-ink: var(--es-color-text-primary);
Renames bump the major. Old name stays as an alias for one minor version, with a @deprecated note pointing at the replacement and a removal version.
padding: 14px 18px;color: #181F2C;border-radius: 6px;Anything not on the scale either belongs on the scale or is a mistake. If a component genuinely needs a value that isn’t tokenised, propose the token first.
Glossary
A short, alphabetised list of every reserved word and what it means in this system.
| Word | Tier · Position | Meaning |
|---|---|---|
| action | semantic · category | Buttons and link-like things. --es-color-action-primary-default. |
| accent | semantic · role | An editorial pop or branded surface. Never the default. |
| content | semantic · category | Text colour by intent (older alias for text in some files). |
| current | state | The page or section the user is on. Maps to aria-current. |
| danger | semantic · role | Destructive or error feedback. Red family. |
| default | semantic · role | The standard variant within a group, e.g. border-default. Never used as a state suffix. |
| disabled | state | Not actionable, removed from tab order. HTML disabled equivalent. |
| editorial | semantic · category | Magazine accents – use sparingly, one per spread. |
| error | state | A field or action has failed validation. Not the same as danger (which is the visual family). |
| feedback | semantic · category | Info, success, warning, danger triplets. |
| focus | state | Keyboard focus visible. Tied to :focus-visible, not :focus. |
| hover | state | Mouse / pointer over an interactive element. Doesn’t apply on touch. |
| inset | semantic · role | Padding inside a container. |
| inline | semantic · role | Horizontal spacing between sibling elements. |
| link | semantic · role | Hyperlinks. Includes linkHover. |
| loading | state | Awaiting an async result. |
| muted | semantic · role | Quietest tier of a surface or text scale. |
| page | semantic · role | The dominant canvas. |
| paper | primitive | The pure off-white. The card-stock surface. |
| pressed | state | Pointer-down. Transient – reverts on release. |
| primary | semantic · role | The first / most prominent variant in its group. |
| raised | semantic · role | A surface above the page. |
| readonly | state | Displays a value, focusable, not editable. Not the same as disabled. |
| secondary | semantic · role | The second-most prominent variant. |
| section | semantic · role | Vertical spacing between page sections. |
| selected | state | Active in a set – tab, list item, multi-select. Implies a peer set. |
| stack | semantic · role | Vertical spacing between sibling elements. |
| strong | semantic · role | The heaviest weight in its group. Borders, dividers. |
| subtle | semantic · role | The lightest weight in its group. Hairlines, soft fills. |
| sunken | semantic · role | A surface below the page. |
| surface | semantic · category | Backgrounds and large planes. |
| tertiary | semantic · role | The third-most prominent variant. Metadata tier in text. |
| text | semantic · category | Foreground type colour by intent. |
| xs / sm / md / lg / xl | variant | The only size vocabulary. Used everywhere a scale step is needed. |