Design Tokens That Survive Contact With Engineering
Most design token systems look great in Figma and fall apart in the codebase. Here's how to structure tokens so they actually hold up across Tailwind, iOS, and three product teams.

Design tokens are the part of a design system everyone agrees on in theory and quietly disagrees on in practice. Designers want expressive names. Engineers want predictable variables. The platform team wants one source of truth. The result, usually, is three half-built token sets and a Slack thread blaming Figma.
We've shipped tokens into Tailwind codebases, React Native apps, and a handful of marketing sites that all needed to share a brand. Here's the structure that actually held up, and the mistakes we keep watching teams repeat.
The mistake: one flat layer of tokens
The most common token setup looks like this:
{
"color-blue-500": "#2563eb",
"color-blue-600": "#1d4ed8",
"color-button-primary": "#2563eb",
"spacing-md": "16px"
}
It looks fine. It is not fine. color-button-primary is a hardcoded hex, not a reference, so when the brand shifts from blue to indigo six months later, someone has to grep the entire repo. Worse, color-blue-500 is being used directly in components, which means the day you rename it to color-indigo-500 you break twelve screens.
Flat tokens fail because they collapse three different jobs into one layer: describing the palette, describing intent, and describing component-specific overrides. Those need to be separate.
The three-tier model that actually scales
The structure we keep coming back to has three layers, each referencing the one above it.
Tier 1: primitives (the raw palette)
These are the literal values. Hex codes, pixel measurements, font families. They are not used directly in components. Ever.
{
"color": {
"blue": {
"500": "#2563eb",
"600": "#1d4ed8"
},
"neutral": {
"50": "#fafafa",
"900": "#171717"
}
},
"space": {
"1": "4px",
"2": "8px",
"4": "16px"
}
}
Name these by what they are, not what they do. blue-500 is honest. brand-blue is a trap, because the day the brand changes you either rename it (breaking everything) or keep a misleading name forever.
Tier 2: semantic tokens (the intent layer)
This is where meaning lives. Semantic tokens reference primitives and describe a role, not a value.
{
"color": {
"background": {
"surface": "{color.neutral.50}",
"surface-inverse": "{color.neutral.900}"
},
"text": {
"primary": "{color.neutral.900}",
"muted": "{color.neutral.600}"
},
"action": {
"primary": "{color.blue.600}",
"primary-hover": "{color.blue.700}"
}
}
}
Semantic tokens are what designers and engineers should actually use 90% of the time. They survive rebrands, they survive dark mode, and they read like English in code review. When you see bg-surface in a PR, you know what it means without opening the design file.
Tier 3: component tokens (the override layer)
These are optional and should be used sparingly. They exist for cases where a component needs to deviate from the semantic defaults in a way that isn't worth promoting to the semantic layer.
{
"button": {
"primary": {
"background": "{color.action.primary}",
"background-hover": "{color.action.primary-hover}",
"padding-x": "{space.4}",
"radius": "{radius.md}"
}
}
}
If you find yourself writing a lot of component tokens, that's a signal your semantic layer is incomplete. Component tokens should be the exception, not the rule.
Why this works under pressure
The three-tier model holds up because each layer changes at a different speed. Primitives change rarely — usually only during a full rebrand. Semantic tokens change occasionally, when product direction shifts (dark mode, a new surface treatment, an accessibility pass). Component tokens change often, but only affect one component at a time.
When a designer says "can we make the danger states warmer," you change one semantic token: color.feedback.danger. Every button, alert, toast, and form field updates. No grep. No PR touching forty files.
From Figma to Tailwind without the manual sync
The Figma-to-code pipeline is where most token systems die. Designers update a variable in Figma, the engineer doesn't notice, and the design system drifts. Here's the workflow we've had luck with.
- Figma Variables as the source of truth for designers. Use Figma's native variables, organized into the same three tiers (primitives in one collection, semantic in another).
- Export to a JSON file using the Tokens Studio plugin or Figma's REST API. Commit this JSON to the design system repo.
- Transform with Style Dictionary into whatever your platforms need: CSS custom properties, a Tailwind config, iOS Swift constants, Android XML.
- Wire into Tailwind by importing the generated CSS variables.
A minimal tailwind.config.js consuming generated CSS variables:
module.exports = {
theme: {
extend: {
colors: {
surface: 'var(--color-background-surface)',
'surface-inverse': 'var(--color-background-surface-inverse)',
'text-primary': 'var(--color-text-primary)',
'action-primary': 'var(--color-action-primary)'
},
spacing: {
1: 'var(--space-1)',
2: 'var(--space-2)',
4: 'var(--space-4)'
}
}
}
}
Now bg-surface text-text-primary in your JSX is reading directly from the token pipeline. Change the JSON, rebuild, done.
The dark mode payoff
This structure makes dark mode almost free. You define a second set of semantic token values that reference different primitives, scoped to a [data-theme="dark"] selector. Components don't change. They keep using bg-surface and text-text-primary — those CSS variables just resolve to different primitives at runtime.
:root {
--color-background-surface: #fafafa;
--color-text-primary: #171717;
}
[data-theme="dark"] {
--color-background-surface: #0a0a0a;
--color-text-primary: #fafafa;
}
No dark: prefixes scattered through your markup. No second component variant. Just a theme switch on the root.
Naming rules that prevent fights
A few conventions we've settled on after losing too many naming arguments:
- Primitives describe the value.
blue-500,space-4,radius-md. Never reference intent. - Semantic tokens describe the role.
action-primary,text-muted,border-subtle. Never reference the underlying color. - Use states as suffixes, not prefixes.
action-primary-hover, nothover-action-primary. It sorts better and reads more naturally. - Avoid "main", "default", or "base". They're meaningless. Use
primary,surface, or just omit the suffix. - Don't encode the platform.
button-primary-backgroundworks everywhere.web-button-bgdoes not.
The contrast trap nobody talks about
Semantic tokens make accessibility easier — but only if you check contrast at the semantic layer, not the primitive layer. blue-500 on neutral-50 might pass WCAG AA. But your semantic pairing might be action-primary on surface-elevated, which resolves to a different combination after a theme tweak.
Build a contrast matrix that tests semantic pairings, not primitives. We run a script in CI that loads the generated CSS variables, walks a list of known pairings (text-primary on surface, text-on-action on action-primary, etc.), and fails the build if any drops below 4.5:1 for body text or 3:1 for large text and UI components. In our experience this catches roughly one regression per quarter that would otherwise ship.
When component tokens earn their keep
We said component tokens should be rare. The cases where they're genuinely useful:
- A component has a brand-specific treatment that doesn't generalize (a hero CTA with a custom gradient).
- A component needs to lock a value that shouldn't drift with semantic changes (a logo lockup with fixed spacing).
- You're shipping a third-party-themable component and need a stable contract for consumers to override.
If the answer to "why does this need a component token?" is "because the designer wanted it slightly different here," the answer is usually to extend the semantic layer instead.
Where we'd start
If you're inheriting a flat token system, don't try to refactor everything in one sprint. Start with color, because it's where rebrand and dark-mode pain hits hardest. Introduce a semantic layer that wraps your existing primitives, migrate one surface (say, the marketing site or the settings page) to use only semantic tokens, and let the contrast check run in CI from day one. Once that's stable, do spacing and typography. Component tokens come last, and only for the cases that genuinely need them.
The goal isn't a perfect taxonomy. It's a system where a designer changing a Figma variable and an engineer reading a Tailwind class are talking about the same thing — and where the rebrand six months from now is a config change, not a migration.
If you want a second pair of eyes on a token architecture before it ships, our design and engineering teams do this kind of work regularly.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Modal Dialogs Are Where Accessibility Goes to Die
Modals look simple in Figma and break in twelve different ways in production. Here's the focus trap, scroll lock, and ESC handling we actually ship — and why the native <dialog> element finally earns its keep in 2026.

Empty States Are Your Best Onboarding Surface — Stop Wasting Them
Most empty states show a sad cloud and a 'No data yet' label. That's a dead pixel. Here's how to turn the zero state into the most persuasive screen in your product.

Skeleton Screens vs Spinners: When Each One Actually Wins
Skeleton screens aren't automatically better than spinners. Here's the decision tree we use on real projects, with code, timing thresholds, and the accessibility traps nobody talks about.
