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

1. Why blue500 eventually breaks your UI

Value tokens are seductive because they look “systematic.” A palette ramp feels like structure: blue100…blue900gray0…gray1000. Early on, it even works—until a value token becomes the name of a design decision.

The failure mode is coupling:

Semantic tokens break the coupling by encoding role:

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.

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

How you keep the surface area sane

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:

The trap is theme forking: darkHighContrastCompactReducedMotion becomes a file, then four files, then a maze.

A scalable model is axes + overlays:

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:

Two practical tips that prevent long-term drift:

  1. Resolve references at build time (by default).
  2. 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.
  3. 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:

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:

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:

  1. Add the new token (minor release).
  2. Keep the old token as an alias to the new one (minor release).
  3. Mark the old token deprecated with metadata (sincereplacedBy, optional removeIn).
  4. Ship a codemod (or automated refactor) to update call sites.
  5. Maintain the alias long enough for real adoption.
  6. Remove only in a major release.

Two rules keep you out of trouble:

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:

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

Fix: aliases + deprecation metadata + codemods + semver; remove only on major.

Adoption checklist

 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.