A token system that works at 50 tokens often starts to break at 500.
The problem is usually governance: no one decided early on who owns the change, how renames propagate, or what happens when two teams need conflicting changes to the same primitive in the same sprint.
Structure has to exist before the numbers force the issue, not after. Most token systems start with whatever naming feels intuitive on day one:
blue-500
primary-color
btn-bg
That works until the token count crosses a couple hundred and it’s no longer clear from the name alone whether something is a primitive, a semantic alias, or a component-specific override.
{
"color": {
"primitive": {
"blue": {
"500": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [0.145, 0.388, 0.922] },
"$description": "Base blue primitive. Never reference directly from components."
}
}
},
"semantic": {
"text": {
"link": {
"$type": "color",
"$value": "{color.primitive.blue.500}",
"$description": "Link text color. Aliases the blue primitive so rebranding only touches one file."
}
}
}
}
}The prefix lets a linter reject components/button.tsx for reaching into color.primitive directly.
The kind of shortcut that, left unchecked, turns every rebrand into a component-by-component hex-code audit instead of a one-line alias change.
Separate primitives from semantics
I’ve workend on token systems where every semantic token was really just a primitive with a friendlier name — color.text.primary hardcoded to the same hex as color.gray.900. No alias, no reference.
In the token file, the two look identical:
{
"color": {
"text": {
"primary": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [0.11, 0.11, 0.13] }
}
}
}
}The problem only shows up the day someone needs a dark theme: there’s no reference to re-point, so every component using this token has to be found and edited by hand.
The fix is simple: primitives hold raw values, semantics hold references, components consume only semantics.
{
"color": {
"text": {
"primary": {
"$type": "color",
"$value": "{color.primitive.gray.900}",
"$description": "Default body text color. References the gray primitive so theme swaps only touch the alias."
}
}
}
}Once that separation exists, a theme swap is a matter of re-pointing aliases, not grepping every component for a hex code.
A linter rule can enforce it directly: flag any semantic token whose $value is a literal instead of a {reference}.
That single check catches the “quick” token additions that skip the semantic layer before they turn into components depending on a primitive directly.
Version tokens like you version an API
A token package with no version history is a package everyone is afraid to touch.
Treat breaking changes (renaming a token, changing a semantic reference, removing a deprecated primitive) the same way you'd treat a breaking API change: a major version bump, a changelog entry, and a deprecation window where the old name still resolves but flags itself.
// deprecated-tokens.js — generated alongside the current token set
export const deprecatedTokens = {
"color.brand.accent": {
replacement: "color.semantic.action.primary",
removeInVersion: "4.0.0",
},
}A build-time check reads this map, resolves the old name to the new value so nothing breaks today, and prints a console warning naming the replacement and the removal version.
This only works if someone owns the decision to bump a major version.
Renaming in place, with no version bump and no deprecation window, is what turns a small change into an incident for every team consuming the package.
Automate propagation, not review
The temptation at scale is to add more manual review. That doesn’t scale, it just shifts the bottleneck from code to people.
What scales is a build pipeline that takes the source token files and generates every platform output — CSS custom properties, iOS assets, Android XML — from the same source of truth, on every merge:
// style-dictionary.config.js
module.exports = {
source: ["tokens/**/*.json"],
platforms: {
css: { transformGroup: "css", buildPath: "build/css/", files: [{ destination: "tokens.css", format: "css/variables" }] },
ios: { transformGroup: "ios", buildPath: "build/ios/", files: [{ destination: "Tokens.swift", format: "ios-swift/class.swift" }] },
android: { transformGroup: "android", buildPath: "build/android/", files: [{ destination: "tokens.xml", format: "android/resources" }] },
},
}Put this in CI so every merge rebuilds all three outputs. Fail the build if any output is out of date—that’s how you know the change reached every platform.
Before shipping, add one more check: run a contrast test and flag any semantic color pair (text/background) that drops below WCAG AA. Token changes can quietly break accessibility across every screen.
Reviewers should focus on the token source diff and the check results—not on manually confirming iOS and web are in sync.

