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?” blue500 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. meaning appearance 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. Value tokens (blue500) drift because they encode appearance, not intent. blue500 appearance intent Semantic tokens (color.text.default) act like a stable API for UI meaning. color.text.default Layer tokens: foundation (raw values) → semantic (roles) → component (optional state contracts). foundation semantic component 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 blue500 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. blue100…blue900 gray0…gray1000 name The failure mode is coupling: Links, buttons, and focus rings all choose blue500 because it’s available.Brand changes the accent ramp; buttons should shift, links shouldn’t.Dark mode needs a different curve for readability; blue500 isn’t “the same blue” anymore.Now you’re stuck: change blue500 and break unrelated UI, or freeze it and add blue500New. Links, buttons, and focus rings all choose blue500 because it’s available. blue500 Brand changes the accent ramp; buttons should shift, links shouldn’t. Dark mode needs a different curve for readability; blue500 isn’t “the same blue” anymore. blue500 Now you’re stuck: change blue500 and break unrelated UI, or freeze it and add blue500New. blue500 blue500New Semantic tokens break the coupling by encoding role: role color.text.link can change independently from color.bg.accent.color.focusRing can be overridden for high contrast without touching link styling.You can keep the palette flexible while keeping the meaning stable. color.text.link can change independently from color.bg.accent. color.text.link color.bg.accent color.focusRing can be overridden for high contrast without touching link styling. color.focusRing 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”). intent paint 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. Foundation tokens are raw materials: palette ramps, spacing scale, radii, durations. Foundation tokens Semantic tokens map those materials to UI roles: text, surfaces, borders, focus, intent. Semantic tokens Component tokens (optional) define a local contract for components that need full state coverage. Component tokens Here’s the layering in one picture: 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) | +------------------------------------------------------------+ +------------------------------------------------------------+ | 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. apps consume semantic (and sometimes component) tokens; foundation stays behind the curtain color.palette.accent.500 3. Treat semantic tokens like a public API Semantic tokens only stay semantic if they’re governed like an API. Stability contract 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. 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 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. Prefer a small, predictable taxonomy: color.text.*, color.bg.*, color.border.*, space.*, motion.*, font.*. 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 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. High contrast: stronger text/surface separation, more visible focus rings, sometimes thicker borders. High contrast Reduced motion: shorter or removed animations, different transitions. Reduced motion Density: spacing and control sizing adjustments (compact vs cozy). Density Typography scaling: often driven by user settings; your tokens must not fight it. Typography scaling The trap is theme forking: darkHighContrastCompactReducedMotion becomes a file, then four files, then a maze. darkHighContrastCompactReducedMotion A scalable model is axes + overlays: 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. 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). motion.* Density overlay: overrides only space.* and sizing-related component tokens. space.* 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). CSS variables for web runtime theming (var(--color-text-default)). CSS variables var(--color-text-default) TypeScript for typed access, tooling, and safe refactors. TypeScript Resolved JSON per theme/axis for pipelines and other languages. Resolved JSON Platform bundles for native environments (constants/resources—format depends on the platform). Platform bundles 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. Resolve references at build time (by default). 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. 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. color.text.default --color-text-default tokens.color.text.default 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. Contrast is a relationship, not a value. Define the pairs you care about and test them across themes: color.text.default on color.bg.canvascolor.text.muted on color.bg.surfacecolor.text.onAccent on color.bg.accentcolor.focusRing against common surfaces color.text.default on color.bg.canvas color.text.default color.bg.canvas color.text.muted on color.bg.surface color.text.muted color.bg.surface color.text.onAccent on color.bg.accent color.text.onAccent color.bg.accent color.focusRing against common surfaces color.focusRing 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. 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.” color.focusRing Localization and RTL need structural support. Localization and RTL need structural support. Don’t encode left/right in token names. Prefer logical naming: space.inline.* and space.stack.* instead of “horizontal/vertical”typography roles (font.size.body, font.lineHeight.body) that can adapt if a script needs different metrics space.inline.* and space.stack.* instead of “horizontal/vertical” space.inline.* space.stack.* typography roles (font.size.body, font.lineHeight.body) that can adapt if a script needs different metrics font.size.body font.lineHeight.body 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, optional removeIn).Ship a codemod (or automated refactor) to update call sites.Maintain the alias long enough for real adoption.Remove only in a major release. 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, optional removeIn). since replacedBy removeIn 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. Don’t change a semantic token’s meaning under the same name. If the meaning changes, introduce a new token. meaning 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 otherexports flatten semantic tokens into artifacts 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("")}}`; } 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)); } }; 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. semantic roles are stable, themes are composed mappings, and exports are generated artifacts 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. Semantic sprawl: hundreds of one-off roles. Semantic sprawl: Fix: keep semantic roles global and reusable; push one-offs into component contracts or local styles. Fix: Foundation leakage: apps import palette values directly. Foundation leakage: Fix: enforce boundaries (lint rules, restricted imports); product code consumes semantic/component only. Fix: State gaps: hover/pressed/disabled/focus not covered consistently. State gaps: Fix: define a state model (stateful semantic roles or component contracts) and require completeness. Fix: Theme explosion: every axis combination becomes a separate theme file. Theme explosion: Fix: compose themes with overlays; test the supported axis matrix explicitly. Fix: Accessibility regressions: palette tweaks break contrast. Accessibility regressions: Fix: automate contrast checks for critical pairs across themes; treat focus visibility as required. Fix: Painful renames: upgrades break downstream apps. Painful renames: Fix: aliases + deprecation metadata + codemods + semver; remove only on major. Fix: 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. 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.