Color Tokens & Semantic Color Architecture
Structured semantic token systems turn raw color values into a maintainable, scalable language that survives brand refreshes, dark mode, and multi-platform delivery.
8 min read
The full lesson
Hard-coded hex values are a technical debt time bomb. The moment a brand refresh arrives — or dark mode lands on the roadmap — every color: #1A73E8 scattered through the codebase becomes a problem. Color tokens fix this by placing a named layer between raw color values and where they are used. Change the value in one place, and it propagates everywhere. Done well, semantic color architecture also makes accessibility explicit, onboards new designers in hours instead of days, and ships consistent UIs across web, iOS, Android, and email with no manual reconciliation.
Why Flat Color Variables Are Not Enough
Many teams move from hard-coded values to a flat token file — a list like --blue-500: #1A73E8 — and consider the job done. This is better than nothing, but it breaks down under real pressure.
A token named blue-500 tells you nothing about when to use it. Should it be the primary action color? A link? An informational badge? When a designer updates the brand palette and blue-500 shifts from cool cobalt to warm indigo, every component that referenced it for a different reason now needs individual review. The token name leaked an implementation detail (the hue and scale step) instead of communicating intent.
The outdated pattern is flat color-named tokens used as if they were semantic: assigning --blue-500 directly to button backgrounds, borders, and focus rings, with no intermediate layer.
The modern answer is a three-tier token hierarchy that separates concerns cleanly.
The Three-Tier Token Model
Tier 1 — Primitive Tokens (the palette)
Primitive tokens are the complete, exhaustive set of raw color values your system can produce. Nothing outside this set should ever appear in component code.
{
"color": {
"blue": {
"100": { "$value": "oklch(97% 0.02 250)", "$type": "color" },
"500": { "$value": "oklch(55% 0.18 250)", "$type": "color" },
"900": { "$value": "oklch(28% 0.12 250)", "$type": "color" }
},
"neutral": {
"0": { "$value": "oklch(100% 0 0)", "$type": "color" },
"50": { "$value": "oklch(97% 0.003 260)", "$type": "color" },
"950": { "$value": "oklch(14% 0.008 260)", "$type": "color" }
}
}
}
Notice the use of OKLCH values. OKLCH is a perceptually uniform color space — unlike HSL, the same L (lightness) value across different hues produces surfaces that actually look the same brightness to a human eye. This matters a lot when building accessible tonal scales, because contrast ratios become predictable by arithmetic rather than trial and error.
Primitives are never referenced directly in components. They are the raw material that semantic tokens draw from.
Tier 2 — Semantic Tokens (the vocabulary)
Semantic tokens map named intents to primitive values. They answer the question: “what is this color for?”
{
"color": {
"action": {
"primary-default": { "$value": "{color.blue.500}", "$type": "color" },
"primary-hover": { "$value": "{color.blue.600}", "$type": "color" },
"primary-pressed": { "$value": "{color.blue.700}", "$type": "color" }
},
"surface": {
"default": { "$value": "{color.neutral.0}", "$type": "color" },
"subtle": { "$value": "{color.neutral.50}", "$type": "color" },
"inverse": { "$value": "{color.neutral.950}", "$type": "color" }
},
"text": {
"primary": { "$value": "{color.neutral.950}", "$type": "color" },
"secondary": { "$value": "{color.neutral.600}", "$type": "color" },
"on-inverse": { "$value": "{color.neutral.0}", "$type": "color" }
},
"feedback": {
"error-default": { "$value": "{color.red.500}", "$type": "color" },
"success-default":{ "$value": "{color.green.500}", "$type": "color" },
"warning-default":{ "$value": "{color.amber.400}", "$type": "color" }
}
}
}
The alias syntax (the curly-brace references like {color.blue.500}) follows the W3C Design Token Community Group (DTCG) stable JSON format. This format uses $value and $type as reserved keys and is now the industry standard. It is supported by Style Dictionary, Tokens Studio for Figma, and most modern token pipeline tools. Avoid proprietary or tool-specific formats — they create lock-in and are harder to diff across platforms.
Semantic tokens are the ones component stylesheets actually import.
Tier 3 — Component Tokens (optional, targeted)
Some teams add a third tier for component-scoped overrides — tokens like button-primary-background that alias a semantic token. This tier is optional. Only add it when a component genuinely needs to deviate from a semantic default in a way that deserves an explicit name rather than an ad-hoc override.
Naming Conventions That Scale
Good token names read as role + prominence + state, not as a color description.
| Pattern | Good example | Bad example |
|---|---|---|
| Role | surface-default | white |
| Role + prominence | text-secondary | grey-600 |
| Role + state | action-primary-hover | blue-hover |
| Feedback + prominence | feedback-error-subtle | light-red |
Avoid encoding hue names in semantic tokens. text-brand-blue will be wrong the day the brand switches to teal. text-brand will stay correct.
Token Formats and the W3C DTCG Standard
The W3C DTCG format reached a stable draft in 2024 and is now the right foundation for new token systems. Key conventions:
- Token metadata lives in
$value,$type,$description, and$extensionskeys - References use the
{path.to.token}alias syntax - Groups are plain JSON objects — no special nesting syntax
- The format is tool-agnostic and works with Style Dictionary, Theo, Cobalt, or any custom build pipeline
Older systems used flat CSS custom properties, Sass maps, or proprietary Figma JSON. These should be migrated. The DTCG format gives you a single source of truth that is version-controlled, diffable, and can be compiled to CSS custom properties, Swift enums, Kotlin objects, and Compose tokens all at once — eliminating the separate-hardcoded-file-per-platform anti-pattern.
Dark Mode as a First-Class Theme
The most common dark mode mistake is inverting light-mode hex values or setting background: #000000. Neither works.
Inverting values breaks perceptual relationships. Colors that were light primaries become dark primaries with no consideration for how the eye perceives contrast at low luminance. Pure black (#000000) creates extreme luminance contrast with content, causes painful halation on OLED displays for users with astigmatism, and offers no elevation affordance.
The correct approach: semantic tokens have two value sets — one for light mode, one for dark mode. A token like surface-default resolves to oklch(100% 0 0) in light mode and oklch(12% 0.008 260) (a near-black with a subtle warm neutral cast) in dark mode. This swap happens at the theme level, not the component level.
:root {
--color-surface-default: oklch(100% 0 0);
--color-text-primary: oklch(14% 0.008 260);
}
[data-theme="dark"] {
--color-surface-default: oklch(12% 0.008 260);
--color-text-primary: oklch(96% 0.004 260);
}
Elevation in dark mode should use luminance steps — slightly lighter surface values — rather than drop shadows, which are nearly invisible on dark backgrounds. A card sits on surface-default. A tooltip or modal sits on surface-raised, which is two or three lightness steps higher.
Accessibility Built Into the Architecture
Semantic tokens are the right place to enforce contrast guarantees. When you define a text/surface pairing as a token pair — text-primary on surface-default — you can test that specific combination once and know it passes everywhere it is used.
WCAG 2.2 AA requires:
- 4.5:1 contrast ratio for normal text (under 18pt / 14pt bold)
- 3:1 for large text and UI components (borders, icons)
Document the contrast ratio for each token pair in your design system. Flag any combination that fails the minimum at the token level — don’t wait to discover it in a component review.
APCA (Advanced Perceptual Contrast Algorithm) is a useful supplementary lens for evaluating how human vision perceives contrast across different font sizes, weights, and polarity (light-on-dark vs. dark-on-light). As of 2026, APCA has not been adopted as a WCAG 3 requirement. Use WCAG 2.2 AA as the legal baseline, and use APCA to catch cases where AA-passing combinations still look perceptually weak.
Do
Define semantic token pairs (text color + surface color) and validate their contrast ratio at the token level. Document the ratio in the token’s $description field so designers and engineers have the guarantee at the point of reference. Name tokens by intent (text-secondary on surface-default) so any engineer can confirm which pair a component uses.
Don't
Hand-pick colors in components and check contrast in a browser extension after the fact. Rely on component-level spot checks instead of architecture-level guarantees. Use color names like grey-400 as semantic tokens — these tell you nothing about whether the combination is accessible or what it is for.
Tooling and the Single Source of Truth
A token system is only useful if it stays synchronized across Figma, web, iOS, and Android. The modern workflow:
- Tokens Studio for Figma (or the native Figma Variables API) holds the authoritative token set in DTCG-compatible format
- Tokens are committed to a Git repository as JSON
- Style Dictionary compiles the JSON into platform outputs: CSS custom properties, Swift
Colorextensions, KotlinColorobjects, Android XML - A CI step validates that all semantic tokens resolve to a primitive, all aliases are non-circular, and all documented contrast pairs still pass AA
This pipeline eliminates the drift that occurs when a developer hard-codes a value “just this once,” or when a designer updates Figma but nobody updates the token file.
For handoff, Figma Dev Mode + Code Connect can surface token names alongside component specs, so engineers see --color-action-primary-default rather than #1A73E8. This is the current best practice — not redline PDFs or Zeplin annotations that drift from the source immediately.
Common Anti-Patterns to Retire
| Anti-pattern | Problem | Fix |
|---|---|---|
color: blue-500 in component code | Leaks primitive, no semantic intent | Map to color-action-primary-default |
| Separate token files per platform | Diverges over time | Single DTCG JSON source, compiled to each platform |
Dark mode by CSS invert() | Breaks images, destroys perceptual relationships | Theme-scoped semantic token overrides |
Token names that include hue (brand-blue) | Wrong when brand color changes | Role-based names (brand-primary) |
| Semantic tokens pointing directly to hex | Skips primitive tier, breaks theme swapping | Semantic tokens alias primitives only |
From Theory to Practice: A Migration Path
Retrofitting tokens into an existing codebase does not require a big-bang rewrite. Use this incremental approach:
- Audit — inventory all color values in use; identify the top 20-30 by frequency
- Define primitives — build a clean OKLCH tonal scale for each hue in use
- Define semantics — for each high-frequency usage, name the intent; resolve against primitives
- CSS custom properties first — introduce CSS variables without changing any visual output; this is a zero-risk first PR
- Migrate components in layers — start with the most-shared leaf components (buttons, inputs, badges); work outward
- Add the dark theme — define dark-mode values for each semantic token; test with
prefers-color-schemeand a manualdata-themeoverride - Lock the pipeline — add a lint rule or CI check that rejects raw color values in component code
Each step is independently shippable and reviewable. A team of two can typically complete steps 1–5 for a mid-size design system in two focused sprints.