Design
A design system that survives handoff — tokens, not screenshots
A 400-frame Figma file is not a design system. It is a very detailed screenshot. Here is the contract that actually holds.
We audited a client's front end six weeks after their design agency had handed over. The CSS contained twenty-three distinct greys. The Figma file contained four. Nobody had done anything wrong — engineers had eyeballed values from a screenshot, designers had nudged a colour in one frame and not the other, and the drift had accumulated at roughly one new grey every two days.
This is the normal outcome of a normal handoff, and the reason is structural. A Figma file is a picture of a design system. It is not the design system. A picture cannot be imported, cannot be versioned meaningfully, and cannot fail a build.
What actually survives handoff is a token file that both sides depend on.
Three tiers, and the middle one is the whole point
Every token system worth having has three layers. Teams routinely build the first and third and skip the second, and then wonder why a rebrand is a three-month project.
Primitives — the raw values
The full palette. Named by what they are, and referenced by nobody except semantic tokens.
--violet-500: #6D5AE6;
--mint-400: #00D9A3;
--coral-500: #FF6B4A;
--ink-950: #0B0B10;
--paper-50: #FAFAF7;
Semantics — the meaning
This is the layer that does the work. Named by role, never by appearance.
--color-surface: var(--paper-50);
--color-surface-raised: var(--white);
--color-text-primary: var(--ink-950);
--color-text-muted: var(--ink-500);
--color-action: var(--violet-500);
--color-action-hover: var(--violet-600);
--color-success: var(--mint-400);
--color-danger: var(--coral-500);
--color-border-subtle: var(--ink-100);
The discipline is absolute: components reference only semantic tokens. Never --violet-500. Never a raw hex.
Two things fall out of this for free, and both are why it matters.
Dark mode becomes a remapping, not a rewrite. You redefine nine semantic tokens under a selector and the entire application follows, including components nobody has looked at in a year:
:root[data-theme="dark"] {
--color-surface: var(--ink-950);
--color-surface-raised: var(--ink-900);
--color-text-primary: var(--paper-50);
--color-text-muted: var(--paper-400);
--color-border-subtle: rgb(255 255 255 / 0.08);
}
If your components reference --gray-100 directly, dark mode is a manual sweep of every file, and you will miss some, and the ones you miss will be in the error states nobody screenshots.
A rebrand becomes a pull request. The client changes their primary colour: you change one primitive. Because no component ever named violet, nothing else moves.
Component tokens — the escape hatch, used sparingly
--button-primary-bg: var(--color-action);
--button-primary-bg-hover:var(--color-action-hover);
--button-radius: var(--radius-md);
Only create these when a component genuinely needs to deviate. If every component has its own token set, you have rebuilt the problem with more indirection.
One source file, many outputs
The tokens live in a tokens.json in the W3C DTCG format, in the repository, reviewed like code. Figma reads it via a plugin; the build compiles it.
{
"color": {
"action": {
"$type": "color",
"$value": "{color.violet.500}",
"$description": "Primary interactive colour. Buttons, links, focus rings."
}
},
"space": {
"4": { "$type": "dimension", "$value": "16px" }
}
}
Style Dictionary compiles that one file into CSS custom properties for the web, a Swift enum for iOS, a Kotlin object for Android, and a Dart class for Flutter. One change, four platforms, no transcription. On a client with a web app and two native apps, this eliminated an entire category of "the Android button is the wrong purple" bug that had previously been a recurring line in every sprint.
With Tailwind 4 the CSS side is now trivially direct — the theme is CSS, so the generated custom properties feed it without a config file in the middle:
/* generated — do not edit */
@import "tailwindcss";
@theme {
--color-surface: #FAFAF7;
--color-action: #6D5AE6;
--spacing-4: 16px;
--radius-md: 0.75rem;
}
Now bg-action and text-muted exist as utilities, generated from the same file the designer edits.
The rule that actually stops the drift
Everything above is a good intention. Good intentions decay. What makes it hold is a check in CI that fails the build on a raw value in a component:
# .github/workflows/ci.yml
- name: No raw colours in components
run: |
if grep -rnE '#[0-9a-fA-F]{3,8}\b|rgba?\(' \
--include='*.tsx' --include='*.css' \
src/components src/app; then
echo "::error::Raw colour found. Use a semantic token."
exit 1
fi
Crude, effective, and — this is the important part — it moves the conversation out of code review. Nobody has to be the person who says "please use the token" for the fortieth time. The machine says it, instantly, without social cost. That single change did more for consistency than any amount of documentation.
(Allow a documented exception list — gradients and SVG illustrations genuinely need raw values. Put them in a directory the grep excludes.)
The component contract
Tokens govern values. They do not govern behaviour, and behaviour is where the other half of the drift happens. Every component in the system ships with a written contract covering:
- Every state. Default, hover, focus-visible, active, disabled, loading, error, empty. If the design does not specify the loading state, the engineer will invent one, and it will not match the one another engineer invents.
- Accessibility. The role, the keyboard interaction, the focus ring (which is a design decision, not an engineering afterthought, and must not be
outline: none), and a minimum 44 × 44 px hit target. - Content limits. What happens with a 60-character button label? With zero rows? With a name in Devanagari that is 30% longer than the English? Specify it, or discover it in production.
- Responsive behaviour at the two or three breakpoints that actually exist, not at every width.
The states matrix is the highest-value document in the entire system. It is also the one designers most often skip, because designing eight states of a button is boring and designing a new hero section is fun.
Type and space are tokens too — and they are where systems get sloppy
Colour tokens get all the attention because colour drift is visible. Spacing and type drift is worse, because it is invisible individually and cumulatively makes a product feel cheap.
Use a fixed scale and make everything off-scale impossible:
--space-1: 4px; --space-2: 8px; --space-3: 12px;
--space-4: 16px; --space-6: 24px; --space-8: 32px;
--space-12: 48px; --space-16: 64px; --space-24: 96px;
--text-xs: 0.75rem/1.5;
--text-sm: 0.875rem/1.6;
--text-base:1rem/1.65;
--text-lg: 1.125rem/1.5;
--text-2xl: 1.5rem/1.25;
There is no 13px. There is no --space-5. If a designer needs 20 px, the answer is that they need 16 or 24, and 95% of the time they agree once asked. The remaining 5% is a real design need and gets a documented exception, which is a conversation worth having.
Line height belongs in the type token, not applied separately, and it must vary by size — 1.65 for body, 1.25 for a display heading. A single global line-height: 1.5 is the most common typography bug in production front ends, and it makes every heading look loose and every long paragraph look tight.
Versioning and deprecation
The system is a package with a version number, consumed by the apps. When a token is retired, it does not vanish — it is marked deprecated, kept for one minor version with a build-time warning that names its replacement, and removed in the next. This is unremarkable software practice and design systems routinely skip it, then break three product teams on a Tuesday with a rename nobody announced.
What not to tokenise
A one-off marketing landing page does not need to be built from system components, and forcing it to be produces a worse page and a polluted system. Let the marketing site have its own bespoke layout; make it consume the colour and type tokens so the brand is right, and leave it there. The system's job is to make the product consistent, not to make every pixel on the internet obedient.
Handoff, as a ritual
What we now do, and it takes about ninety minutes:
- Designers and engineers open the token file together, not the Figma file. Every token is read aloud and someone says what it is for. Half the questions are caught here.
- Engineers build the states matrix as a Storybook page first, before any feature work. Designers review Storybook, not Figma, from that day forward. If it is not in Storybook, it does not exist.
- Any new token requires a pull request with a reason. If the answer to "why do you need a new grey?" is "the design has one", the design is wrong, and that is a much easier conversation to have in a PR than in month six.
The handoff is not a moment where designers give engineers a file. It is the point at which both teams start depending on the same source of truth. If that source is a picture, it will rot. If it is a token file in the repository, with a CI check behind it, it will hold.
Written by
PRS Admin
Building software at PRS India.