A value token like blue500 is the UI equivalent of duct tape: fast, satisfying, and quietly structural. One day it’s your link color. The next day it’s your primary button background. Then it becomes the focus ring because “it’s the same blue, right?”
Now add the real world: dark mode, high-contrast requirements, reduced motion preferences, density modes, a palette refresh, and multiple platforms that all render color and type a little differently. That single token becomes a brittle dependency shared by unrelated features.
Semantic tokens fix this by making tokens describe meaning (what a value is for), not appearance (what the value happens to be today). Once meaning is stable, you can change the underlying values—per theme, per platform, per accessibility mode—without breaking every consumer.
TL;DR
- Value tokens (
blue500) drift because they encode appearance, not intent. - Semantic tokens (
color.text.default) act like a stable API for UI meaning. - Layer tokens: foundation (raw values) → semantic (roles) → component (optional state contracts).
- Compose themes by axes (light/dark + contrast + motion + density) instead of forking theme files.
- Export from one source of truth into CSS variables, TypeScript, JSON, and platform bundles.
- Bake accessibility and localization into the model (contrast pairs, focus visibility, logical direction, typography roles).
- Evolve safely with aliases, deprecations, codemods, and semver.
1. Why blue500 eventually breaks your UI
Value tokens are seductive because they look “systematic.” A palette ramp feels like structure: blue100…blue900, gray0…gray1000. Early on, it even works—until a value token becomes the name of a design decision.
The failure mode is coupling:
- Links, buttons, and focus rings all choose
blue500because it’s available. - Brand changes the accent ramp; buttons should shift, links shouldn’t.
- Dark mode needs a different curve for readability;
blue500isn’t “the same blue” anymore. - Now you’re stuck: change
blue500and break unrelated UI, or freeze it and addblue500New.
Semantic tokens break the coupling by encoding role:
color.text.linkcan change independently fromcolor.bg.accent.color.focusRingcan be overridden for high contrast without touching link styling.- You can keep the palette flexible while keeping the meaning stable.
A simple rule: product code should ask for intent (“default text on surface”), not paint (“blue 500”).
2. Token layering that scales: foundation → semantic → component
Scaling tokens is mostly about putting the right ideas in the right layer.
- Foundation tokens are raw materials: palette ramps, spacing scale, radii, durations.
- Semantic tokens map those materials to UI roles: text, surfaces, borders, focus, intent.
- Component tokens (optional) define a local contract for components that need full state coverage.
Here’s the layering in one picture:
+------------------------------------------------------------+
| COMPONENT |
| button.bg, button.fg, button.border, button.focusRing |
| (optional: per-component contracts & state matrices) |
+----------------------------↑--------------------------------+
|
+----------------------------|--------------------------------+
| SEMANTIC |
| color.text.default, color.bg.canvas, color.border.subtle |
| color.text.link, color.focusRing, motion.duration.standard |
| (stable names; theme/axis mapping happens here) |
+----------------------------↑--------------------------------+
|
+----------------------------|--------------------------------+
| FOUNDATION |
| color.palette.neutral.* / accent.*, space.*, radius.*, |
| font.*, duration.*, easing.* |
| (raw values; can evolve as long as semantics remain true) |
+------------------------------------------------------------+
The most important governance move is a dependency rule: apps consume semantic (and sometimes component) tokens; foundation stays behind the curtain. If engineers can import color.palette.accent.500 directly, they will—because deadlines exist.
3. Treat semantic tokens like a public API
Semantic tokens only stay semantic if they’re governed like an API.
Stability contract
- Semantic names are stable: renames/removals are breaking.
- Foundation can evolve: add ramps, tune values, adjust scales.
- Component contracts are scoped-stable: they change with component APIs, not random app needs.
How you keep the surface area sane
- Prefer a small, predictable taxonomy:
color.text.*,color.bg.*,color.border.*,space.*,motion.*,font.*. - Use the “rule of three”: if a role appears in three places, it may deserve a semantic token; otherwise keep it component- or local-scoped.
- Avoid semantic tokens named after screens or features. Roles are reusable; screens are not.
Change process
Write token changes like RFCs: what problem, what layer, what themes/axes, what accessibility constraints, what migration plan. If you can’t answer those, you’re not adding a token—you’re adding future debt.
4. Theming by composition: light/dark + contrast + motion + density
Light and dark mode are just the opening act. Real theming includes:
- High contrast: stronger text/surface separation, more visible focus rings, sometimes thicker borders.
- Reduced motion: shorter or removed animations, different transitions.
- Density: spacing and control sizing adjustments (compact vs cozy).
- Typography scaling: often driven by user settings; your tokens must not fight it.
The trap is theme forking: darkHighContrastCompactReducedMotion becomes a file, then four files, then a maze.
A scalable model is axes + overlays:
- Base mapping: light or dark.
- Contrast overlay: overrides only the tokens that should change for contrast.
- Motion overlay: overrides only
motion.*(and possibly some component behaviors). - Density overlay: overrides only
space.*and sizing-related component tokens.
This keeps testing feasible: you validate a small, explicit cartesian product of supported axes instead of maintaining dozens of handcrafted themes.
5. Cross-platform packaging: one source, many exports
Tokens become real when they’re easy to consume on every platform your UI touches.
From one canonical token source, generate:
- CSS variables for web runtime theming (
var(--color-text-default)). - TypeScript for typed access, tooling, and safe refactors.
- Resolved JSON per theme/axis for pipelines and other languages.
- Platform bundles for native environments (constants/resources—format depends on the platform).
Two practical tips that prevent long-term drift:
- Resolve references at build time (by default).
- Let semantic tokens reference foundation tokens, and resolve to concrete values when producing artifacts. Runtime resolution is possible, but it adds complexity and makes failures harder to debug.
- Keep naming conversions deterministic.
color.text.default should map predictably to --color-text-default (CSS) and tokens.color.text.default (TS). Inconsistent naming is a silent adoption killer.
6. Accessibility and localization: make the right thing easy
A token system is an accessibility system whether you planned it or not.
Contrast is a relationship, not a value.
Define the pairs you care about and test them across themes:
color.text.defaultoncolor.bg.canvascolor.text.mutedoncolor.bg.surfacecolor.text.onAccentoncolor.bg.accentcolor.focusRingagainst common surfaces
Automate those checks. If palette tuning can ship without re-running contrast validation, you’re one refactor away from shipping unreadable UI.
Focus should be first-class.
Make color.focusRing (and optionally focus width tokens) explicit. High-contrast modes often need a different focus treatment than “same blue, brighter.”
Localization and RTL need structural support.
Don’t encode left/right in token names. Prefer logical naming:
space.inline.*andspace.stack.*instead of “horizontal/vertical”- typography roles (
font.size.body,font.lineHeight.body) that can adapt if a script needs different metrics
Tokens can’t solve every localization issue, but they can stop you from hardcoding assumptions that make localization expensive later.
7. Migration without breakage: aliases, deprecations, codemods
If tokens are an API, migrations should feel like API evolution—not like a weekend-long search-and-replace ritual.
A safe deprecation lifecycle:
- Add the new token (minor release).
- Keep the old token as an alias to the new one (minor release).
- Mark the old token deprecated with metadata (
since,replacedBy, optionalremoveIn). - Ship a codemod (or automated refactor) to update call sites.
- Maintain the alias long enough for real adoption.
- Remove only in a major release.
Two rules keep you out of trouble:
- Don’t change a semantic token’s meaning under the same name. If the meaning changes, introduce a new token.
Add a completeness check: every supported theme/axis must define every required semantic token. Undefined tokens should fail builds, not production.
8. A minimal implementation: schema + exports, then theme mapping
Below is a compact pattern that works well in practice:
- tokens are layered and reference each other
- exports flatten semantic tokens into artifacts
themes are override maps composed by axes
Code snippet 1 — Token schema + export
type Token = { $type: "color" | "dimension" | "duration"; $value: string };
type Tree = { [k: string]: Token | Tree };
export const tokens: Tree = {
foundation: {
color: { neutral: { "0": { $type: "color", $value: "#fff" }, "900": { $type: "color", $value: "#111" } },
accent: { "500": { $type: "color", $value: "#2f6bff" }, "600": { $type: "color", $value: "#1f54d6" } } },
space: { "2": { $type: "dimension", $value: "8px" }, "4": { $type: "dimension", $value: "16px" } },
motion: { normal: { $type: "duration", $value: "200ms" } }
},
semantic: {
color: {
text: { default: { $type: "color", $value: "{foundation.color.neutral.900}" },
link: { $type: "color", $value: "{foundation.color.accent.600}" } },
bg: { canvas: { $type: "color", $value: "{foundation.color.neutral.0}" },
accent: { $type: "color", $value: "{foundation.color.accent.500}" } },
focusRing: { $type: "color", $value: "{foundation.color.accent.500}" }
},
space: { inline: { md: { $type: "dimension", $value: "{foundation.space.4}" } } },
motion: { duration: { standard: { $type: "duration", $value: "{foundation.motion.normal}" } } }
}
};
const get = (t: any, p: string) => p.split(".").reduce((a, k) => a?.[k], t);
const resolve = (v: string): string => v.startsWith("{") ? resolve(get(tokens, v.slice(1, -1)).$value) : v;
export function exportCssVars(layer: "semantic") {
const out: string[] = [];
const walk = (node: any, path: string[] = []) =>
Object.entries(node).forEach(([k, v]) =>
(v as any).$value ? out.push(`--${path.concat(k).join("-")}:${resolve((v as any).$value)};`)
: walk(v, path.concat(k))
);
walk((tokens as any)[layer]);
return `:root{${out.join("")}}`;
}
Code snippet 2 — Theme mapping + consumption
type Axes = { mode: "light" | "dark"; contrast: "normal" | "high"; motion: "full" | "reduced"; density: "cozy" | "compact" };
type Overrides = Record<string, string>; // token path -> raw value or "{ref.path}"
const baseLight: Overrides = { "semantic.color.text.default": "{foundation.color.neutral.900}", "semantic.color.bg.canvas": "{foundation.color.neutral.0}" };
const baseDark: Overrides = { "semantic.color.text.default": "{foundation.color.neutral.0}", "semantic.color.bg.canvas": "{foundation.color.neutral.900}" };
const hiContrast: Overrides = { "semantic.color.focusRing": "{foundation.color.accent.600}" };
const reducedMotion: Overrides = { "semantic.motion.duration.standard": "0ms" };
const compact: Overrides = { "semantic.space.inline.md": "{foundation.space.2}" };
export const buildTheme = (a: Axes): Overrides => ({
...(a.mode === "light" ? baseLight : baseDark),
...(a.contrast === "high" ? hiContrast : {}),
...(a.motion === "reduced" ? reducedMotion : {}),
...(a.density === "compact" ? compact : {})
});
// Web example: turn overrides into CSS variables (path -> --path-with-dashes) and apply to :root.
export const applyTheme = (overrides: Overrides, resolveRef: (v: string) => string) => {
for (const [path, v] of Object.entries(overrides)) {
document.documentElement.style.setProperty("--" + path.replace(/\./g, "-"), resolveRef(v));
}
};
The point isn’t these exact shapes—it’s the architecture: semantic roles are stable, themes are composed mappings, and exports are generated artifacts. Once that’s true, scaling to more platforms becomes packaging work, not a rewrite.
Pitfalls & Fixes
- Semantic sprawl: hundreds of one-off roles.
- Fix: keep semantic roles global and reusable; push one-offs into component contracts or local styles.
- Foundation leakage: apps import palette values directly.
- Fix: enforce boundaries (lint rules, restricted imports); product code consumes semantic/component only.
- State gaps: hover/pressed/disabled/focus not covered consistently.
- Fix: define a state model (stateful semantic roles or component contracts) and require completeness.
- Theme explosion: every axis combination becomes a separate theme file.
- Fix: compose themes with overlays; test the supported axis matrix explicitly.
- Accessibility regressions: palette tweaks break contrast.
- Fix: automate contrast checks for critical pairs across themes; treat focus visibility as required.
- Painful renames: upgrades break downstream apps.
Fix: aliases + deprecation metadata + codemods + semver; remove only on major.
Adoption checklist
- Define the three layers and the dependency rule (apps don’t consume foundation).
- Start with a small semantic taxonomy: text, bg, border, focus, intent, space, motion, font.
- Declare supported theme axes and implement them via base + overlays.
- Generate exports for CSS variables, TS, resolved JSON, and platform bundles.
- Add completeness checks (every theme defines every required semantic token).
- Automate accessibility checks (contrast pairs, focus visibility) across themes.
- Ship migration tooling (aliases, deprecations, codemods) and a changelog.
Create a lightweight RFC template for new semantic tokens and breaking change?
Conclusion
If you take one thing from this: stop asking your UI for paint (blue500) and start asking for intent (color.text.link). Once your semantic layer is stable, themes become mapping work—not a rewrite—and migrations become API evolution, not chaos.
Start small: lock down the layer boundary (no foundation imports in product code), define a tight semantic taxonomy, and automate completeness + contrast checks. From there, scale with overlays and exports.
