Skip to content

Writing

Bridging Figma and code: a design system built on tokens and Tailwind

Three token layers, one configuration knob for a whole colour ramp, and contrast ratios computed by a test rather than typed into a comment — how to build a design system where the code and the design file cannot drift apart.

6 min read

Most design systems fail in the same place. Not at the start, when the palette is agreed and the components are drawn, but eighteen months in — when a colour has been adjusted in Figma and not in code, when a fourth grey has appeared because nobody could find the third, and when nobody can say which of two nearly identical buttons is the real one.

The fix is not more documentation. It is making the drift impossible to commit.

Three layers, and each answers a different question

A single flat list of tokens — blue-500, gray-200, spacing-4 — cannot express intent, so every usage becomes a decision made again. Three layers fix that.

LayerExampleQuestion it answers
Primitive--accent-hue: 262What values exist?
Semantic--color-accent-textWhat does this value mean?
Component--ts-focus-ringWhat does this part use?
Each layer names what the one below it means. A component only ever reaches for the layer above it.

The rule that makes it work is that a component may only use the semantic layer. A hex code in a component is a bug; so is reaching past the semantic name to the primitive underneath it.

css
/* ✗ a decision made again, in a place nobody will find */
.cta { background: #2563eb; }
 
/* ✓ the decision was made once, and this is a reference to it */
.cta { background: var(--color-accent); }

One knob for a whole ramp

The strongest single move in this system is that the entire accent ramp is derived from one number.

css
@theme {
  --accent-hue: 262;
  --accent-chroma: 0.215;
 
  --color-accent:      oklch(0.546 var(--accent-chroma) var(--accent-hue));
  --color-accent-text: oklch(0.714 0.143 var(--accent-hue));
  --color-accent-dim:  oklch(0.42 0.12 var(--accent-hue));
}

Rotate --accent-hue and the whole site rebrands. Nothing else hardcodes the colour, which is enforceable — a test greps for hex literals outside the token file.

Authoring in OKLCH rather than hex is what makes that safe, because OKLCH lightness is perceptually uniform: two colours with the same L look equally light to a human eye, regardless of hue.

Contrast is computed, never typed

The most common way a design system lies is a comment.

css
/* ✗ This was true when it was written. It is a claim with no mechanism. */
--color-text-dim: #6b7684;   /* 4.6:1 on surface — passes AA */

Implement the WCAG relative luminance formula once, and assert against it:

ts
export function contrast(a: string, b: string): number {
  const l1 = relativeLuminance(a);
  const l2 = relativeLuminance(b);
  const [light, dark] = l1 > l2 ? [l1, l2] : [l2, l1];
  return (light + 0.05) / (dark + 0.05);
}
ts
// Every text token against EVERY surface, not just the one it was designed on.
for (const [name, colour] of Object.entries(textTokens)) {
  for (const [surfaceName, surface] of Object.entries(surfaces)) {
    expect(contrast(colour, surface), `${name} on ${surfaceName}`).toBeGreaterThanOrEqual(4.5);
  }
}

That last detail is not incidental. Our dim text token moved twice: the first value failed on the ground, and the replacement passed on one surface and failed at 4.45:1 on another — because the original check only tested one background. The guarantee was checking the wrong worst case.

And a detail that catches nearly everyone: font-weight: 600 is not bold. WCAG's large-text exemption at 18.66px requires bold, which starts at 700 — so a semibold heading has to clear the small-text bar until it reaches 24px.

Tailwind v4: the theme is the token file

Tailwind v4 moves configuration into CSS, which removes an entire class of drift — there is no longer a JavaScript config and a stylesheet that can disagree.

css
@import 'tailwindcss';
 
@theme {
  --color-ground: #0a0c0f;
  --color-surface-1: #12161c;
  --color-text: #e6e9ee;
 
  --text-body: 1rem;
  --text-lead: clamp(1.0625rem, 1rem + 0.45vw, 1.3125rem);
 
  --radius: 2px;
  --duration-base: 240ms;
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}

Every token is now both a CSS custom property and a Tailwind utility. bg-ground and var(--color-ground) are the same declaration, so a component author and a stylesheet author cannot diverge.

One sharp edge in a custom type scale

tailwind-merge classifies unknown utilities by guessing from the name, and it read our text-body size token as a colour. So cn('text-white', 'text-body') silently dropped text-white — and the primary button shipped at 4.25:1, failing AA. The fix is to register the custom scale with extendTailwindMerge; the lesson is that a merge utility making assumptions about your naming is a place bugs hide silently.

Bridging the file and the code

Figma variables and CSS custom properties are the same idea in two tools, which makes a one-directional pipeline possible.

  1. Name identically in both

    color/accent/text in Figma is --color-accent-text in CSS. A mechanical mapping is worth more than a prettier name in either place.

  2. Pick one source of truth and generate the other

    Export Figma variables to JSON and generate the @theme block, or generate a Figma variables file from the tokens. Which direction matters less than the fact that one side is generated and committed, so a change shows up in a diff.

  3. Put the constraints on the generated side

    The contrast tests run against the generated tokens. A designer changing a value in Figma then gets a failing build rather than a shipped accessibility defect.

  4. Publish a live styleguide route

    Every token rendered on every surface, with computed PASS/FAIL badges beside it. It is the fastest possible answer to "which grey should I use" and it cannot go stale, because it renders the real values.

Rules worth enforcing mechanically

These are ours; the specific values matter less than the fact that each is a build failure rather than a convention.

  1. No hex literals in components

    One exception, documented at the exception: a favicon is fetched outside the document, so no stylesheet reaches it and var() resolves to nothing. A test recomputes the colour from the hue knob and fails if the committed icon went stale.

  2. No shadows

    Elevation is a surface step plus a hairline border. Shadows on a near-black background are invisible or muddy, and a system with them accumulates six slightly different ones.

  3. One radius value, used everywhere

    Ours is 2px, because the brand is cut rather than rounded — corners are chamfered by a clip-path utility instead. The point is the singular, not the number.

  4. One motion scale

    Two durations and two easings. A component inventing its own timing is how a system stops feeling like one thing.

The short version

A design system is not a component library. It is a set of decisions plus the machinery that stops them being re-made: three token layers so intent is expressible, one configuration knob so a rebrand is one line, computed contrast so a claim cannot go stale, and a styleguide route that renders the real values rather than a screenshot of them.

Every check above is cheap on day one and expensive after two hundred usages. That asymmetry is the whole argument for doing it first.

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.