Skip to main content
Executive Support by Beige Threat

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.

Tiers3 – primitive, semantic, component
Grammar--es-<category>-<role>[-<variant>][-<state>]
Casingkebab-case
Prefix--es- (always)
FormatW3C Design Tokens (DTCG)
Enforced byStylelint, 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.

Tier 1

Primitive

The raw value. Scale-indexed or literal – named by what it is, never by where it’s used. Stable identity, no intent.

—es-color-teal-600
—es-space-4
—es-radius-md
—es-duration-base
Consumed by: only semantic tokens. Components never bind to a primitive when a semantic exists.
Tier 2

Semantic

Intent-named. Says what role the value plays in the system. Can re-bind to a different primitive without renaming.

—es-color-text-primary
—es-color-border-subtle
—es-spacing-stack-md
—es-radius-card
Consumed by: component tokens, and patterns without a component layer (page chrome, hero blocks).
Tier 3

Component

Scoped to one component. Re-skinning a component means rebinding these – never editing the component’s CSS.

—es-button-primary-bg
—es-field-border-focus
—es-card-padding-lg
Consumed by: the component CSS itself, and only that.

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”).

Segments
--es-
Required · prefix

Project namespace. Avoids collisions with browser, framework, and third-party custom properties.

—es-color-navy-900
category
Required · token family

The group: color, space, radius, shadow, motion, font, duration, easing – plus one per component (button, field, card…).

—es-color-text-primary
—es-space-4
—es-button-primary-bg
role
Required · what it does

Inside the category, what role this token plays. primary, strong, muted, focus, danger, raised, stack.

—es-color-text-primary
—es-spacing-stack-md
variant
Optional · sub-flavour or part

A part within a triplet (surface, border, text in feedback) or a step on a scale (sm, md, lg, 600).

—es-color-feedback-danger-surface
—es-color-teal-600
state
Optional · interaction state

Always last. Drawn from the reserved vocabulary: hover, pressed, focus, selected, disabled, error.

—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.

Surface
page
role · primary canvas

The dominant background. Cream in editorial pages; paper in product UIs.

—es-color-surface-page
raised
role · above the page

Cards, modals, popovers. A surface that sits above the canvas.

—es-color-surface-raised
sunken
role · below the page

Sidebars, alt sections, inset wells. Recedes from the canvas.

—es-color-surface-sunken
muted
role · tertiary panel

Subtle background tint for tertiary content. Quieter than sunken.

—es-color-surface-muted
Text
primary
role · body & headings

The default ink for content on light surfaces.

—es-color-text-primary
secondary
role · supporting

Captions, supporting copy, muted prose.

—es-color-text-secondary
tertiary
role · metadata

Timestamps, helper text, breadcrumbs. Quietest tier.

—es-color-text-tertiary
disabled
state · not actionable

Reserved for the disabled state. Don't use as a 'quiet' colour.

—es-color-text-disabled
Border
subtle
role · hairline

Dividers between rows, table cells, list items.

default
role · controls & cards

The standard border on inputs, cards, and most containers.

strong
role · separators

Heavier separators in dense UIs and data tables.

focus
state · keyboard focus

Always teal. Brand-locked. Never rebound for theming.

Size scale
xs / sm / md / lg / xl
variant · t-shirt sizes

The only size vocabulary. Used for spacing slots, radii, shadows, distances. No 'tiny', 'huge', 'jumbo'.

A 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.

Allowed
component → semantic
the standard path

A component token resolves to a semantic token. The button changes when the semantic re-binds.

—es-button-primary-bg: var(—es-color-action-primary-default);
semantic → primitive
the standard path

A semantic token resolves to a primitive. This is where modes and themes do their work.

—es-color-action-primary-default: var(—es-color-teal-600);
component → primitive (only when no semantic exists)
the escape hatch

Permitted when there is no meaningful semantic – e.g. a component-specific radius that no other component shares.

—es-card-padding-lg: var(—es-space-6);
Forbidden
component → primitive (when a semantic exists)
skips the contract

Locks the component to the primitive's value across every mode. Defeats the semantic layer entirely.

—es-button-primary-bg: var(—es-color-teal-600);
component → component
couples siblings

Two components now share fate. Restyling one drags the other. Each component owns its own surface contract.

—es-field-border-focus: var(—es-button-primary-bg);
semantic → component
reverses the flow

Semantic tokens are the system's vocabulary; they cannot depend on a single component's choices.

—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.

ChangeBumpWhat ships
Add a new tokenMinorNo consumer breaks. Documented under Added.
Adjust a primitive value within tolerancePatche.g. a hex shift < 1 ΔE. Re-run the contrast matrix.
Adjust a semantic bindingMinorVisible to consumers. Documented under Changed.
Rename a tokenMajorOld name kept as an alias for one minor version, marked @deprecated. Removed at the next major.
Remove a tokenMajorMust be deprecated for ≥ 1 minor version first. No drive-by removals.
Promote a primitive to semanticMajorOld 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

Do · Use semantic tokens in components
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.

Don't · Reach into primitives from a component
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.

Do · Primitives only on foundation pages
/* 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.

Don't · Encode the value in the name
—es-text-navy—es-border-2px—es-bg-red

The first rebrand renames every consumer. Worse: it tempts an engineer to use the wrong token because the colour matches.

Do · Drop optional segments when empty
—es-color-surface-page—es-button-primary-bg

Concise. The grammar absorbs missing variants and states without filler. The absence of a state segment is the default.

Don't · Pad with filler segments
—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?

Do · One source of truth: the token JSON
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.

Don't · Hand-edit generated CSS
/* 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.

Do · Treat a rename as a contract change
/* @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.

Don't · Magic numbers in component CSS
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.

WordTier · PositionMeaning
actionsemantic · categoryButtons and link-like things. --es-color-action-primary-default.
accentsemantic · roleAn editorial pop or branded surface. Never the default.
contentsemantic · categoryText colour by intent (older alias for text in some files).
currentstateThe page or section the user is on. Maps to aria-current.
dangersemantic · roleDestructive or error feedback. Red family.
defaultsemantic · roleThe standard variant within a group, e.g. border-default. Never used as a state suffix.
disabledstateNot actionable, removed from tab order. HTML disabled equivalent.
editorialsemantic · categoryMagazine accents – use sparingly, one per spread.
errorstateA field or action has failed validation. Not the same as danger (which is the visual family).
feedbacksemantic · categoryInfo, success, warning, danger triplets.
focusstateKeyboard focus visible. Tied to :focus-visible, not :focus.
hoverstateMouse / pointer over an interactive element. Doesn’t apply on touch.
insetsemantic · rolePadding inside a container.
inlinesemantic · roleHorizontal spacing between sibling elements.
linksemantic · roleHyperlinks. Includes linkHover.
loadingstateAwaiting an async result.
mutedsemantic · roleQuietest tier of a surface or text scale.
pagesemantic · roleThe dominant canvas.
paperprimitiveThe pure off-white. The card-stock surface.
pressedstatePointer-down. Transient – reverts on release.
primarysemantic · roleThe first / most prominent variant in its group.
raisedsemantic · roleA surface above the page.
readonlystateDisplays a value, focusable, not editable. Not the same as disabled.
secondarysemantic · roleThe second-most prominent variant.
sectionsemantic · roleVertical spacing between page sections.
selectedstateActive in a set – tab, list item, multi-select. Implies a peer set.
stacksemantic · roleVertical spacing between sibling elements.
strongsemantic · roleThe heaviest weight in its group. Borders, dividers.
subtlesemantic · roleThe lightest weight in its group. Hairlines, soft fills.
sunkensemantic · roleA surface below the page.
surfacesemantic · categoryBackgrounds and large planes.
tertiarysemantic · roleThe third-most prominent variant. Metadata tier in text.
textsemantic · categoryForeground type colour by intent.
xs / sm / md / lg / xlvariantThe only size vocabulary. Used everywhere a scale step is needed.