Separate raw values from semantic roles
A primitive token names a selected value or scale position, for example --color-indigo-600. A semantic token names an interface role, for example --color-action. Mapping the semantic token to the primitive creates one controlled indirection. Components use --color-action and do not need to know whether the current theme maps it to indigo, blue, or another reviewed color. This separation also makes audits clearer: primitive values are checked for provenance and conversions, while semantic pairs are checked for contrast and state meaning. Avoid exposing every palette value to every component without a concrete use.
Semantic does not mean subjective marketing language. Prefer roles that can be verified in the interface: surface, surface-raised, text, text-muted, border, focus-ring, action, on-action, danger, and on-danger. Each foreground token should have documented backgrounds on which it is supported. A token called light-gray carries no relationship or usage guarantee; text-muted can have a contract stating where it may appear. Do not encode a current theme into the semantic name. Names such as white-text or dark-surface make a future theme override confusing and encourage components to rely on appearance rather than purpose.
Understand cascade and inheritance
Custom properties participate in the cascade and normally inherit. A declaration on :root becomes available to descendants, but a nearer declaration can override it by ordinary cascade rules. Names are case-sensitive, so --color-text and --Color-text are different properties. var() substitutes the computed custom-property value where it is used. This power makes local theming straightforward, but it can also create hidden dependencies if components redefine global names casually. Keep global contracts at a documented root or theme boundary and use component-specific prefixes for values intended to be private to a component.
:root {
--color-indigo-600: #4f46e5;
--color-slate-950: #020617;
--color-surface: #ffffff;
--color-text: var(--color-slate-950);
--color-action: var(--color-indigo-600);
}Keep the first token set intentionally small
Start with the roles present in real screens instead of generating hundreds of speculative levels. A small product often needs two or three surfaces, primary and muted text, a border, one action pair, focus indication, and a limited set of status pairs. Record which components consume every token. If a token has no consumer, it may not belong in the initial contract. Primitive ramps are useful only when they support real semantic decisions. A narrow system is easier to test across themes and states, and additions can be reviewed with evidence rather than inherited from an arbitrary design-tool export.
- Surface and text pairs used by real layouts.
- Action and on-action colors for controls with text or icons.
- Border and focus-ring values with state-specific evidence.
- Status pairs only for statuses the product actually communicates.
Name tokens by purpose and state
Use a consistent hierarchy and vocabulary. One practical pattern is --color-<role> with modifiers for emphasis or state, while primitive values use --color-<hue>-<step>. The exact convention matters less than avoiding synonyms such as primary, brand, accent, and action for the same job. State tokens should express hover, active, disabled, focus, or selected only when the state truly has a distinct value. A table connecting token, allowed background, owning feature, and contrast result is more valuable than a visual grid alone. Keep names stable when only the underlying value changes.
| Layer | Example | Responsibility |
|---|---|---|
| Primitive | --color-indigo-600 | Record a selected color value |
| Semantic | --color-action | Describe a UI role |
| Component | --button-background | Adapt a role inside one component |
| State | --color-action-hover | Describe an evidenced interaction state |
Provide deliberate fallbacks and themes
The second argument of var() is a fallback used when the referenced custom property is missing or invalid at substitution time. It is not a general browser-support fallback and a comma may belong to the fallback value. Provide fallbacks at integration boundaries where a component may run without the host theme; avoid repeating raw values throughout product code, because that defeats central control. Theme overrides should replace semantic mappings at one scoped boundary. A media query can select a default preference, while an explicit application choice can use an attribute or class with clearly defined precedence. The sample therefore overrides the dark surface and its text together, yielding an explicit pair instead of leaving dark text on a dark surface.
.button {
color: var(--color-on-action, #ffffff);
background: var(--color-action, #4f46e5);
border: 1px solid var(--color-border, #cbd5e1);
}
@media (prefers-color-scheme: dark) {
:root {
--color-surface: #0f172a;
--color-text: #f8fafc;
}
}Test combinations, not isolated swatches
A color token is not accessible in isolation. Test supported foreground and background pairs after alpha compositing, and cover hover, focus, active, disabled, selected, validation, and high-contrast adaptations. Verify that focus is not communicated only by a subtle color change. Snapshotting token strings cannot establish contrast; tests need resolved values and the actual pairing rules. Also inspect forced-colors behavior rather than fighting user-agent substitutions. Document whether a token is decorative, text-bearing, or used for a non-text graphical object, because the relevant accessibility requirements differ.
- Check inheritance and local overrides in nested components.
- Resolve every supported foreground/background combination.
- Test preference, explicit theme, and forced-colors behavior.
- Audit unused, duplicate, and deprecated tokens before adding more.
Evolve tokens without breaking consumers
Treat token changes as API changes. Search consumers, update documentation and screenshots, measure affected pairs, and release aliases or a migration when renaming a public token. Remove deprecated aliases only after every consumer has moved. Keep the canonical source under version control and generate platform-specific outputs only when generation is deterministic and tested. Do not let JavaScript duplicate the theme table if CSS can own it. A small changelog explaining which semantic decision changed helps reviewers distinguish a visual adjustment from a breaking change in component assumptions.
Primary sources
The factual claims in this article are checked against these published specifications.