Design systems

Design tokens as a contract between the client and the renderer

We have two runtimes that draw the same brand. One is a React Native application that renders on iOS, Android and the web. The other is a Node service that composes images on the server. They share no code, no styling engine and no lifecycle – and they have to agree, pixel for pixel, on what our colours are.

The naive version of this is a constants file copied into both. It works until the first time someone adjusts a shade in one of them, and then you have two brands.

Tokens as data, resolved on one side

We define the design system once, as plain data: colour roles, type scale, spacing steps, radii. Not blue600, but the role it plays – surface, text, accent, muted. The application consumes that data directly. The rendering service does not: it never learns what a theme is.

Instead, every render request carries a resolved token set. The caller decides which profile applies and sends the concrete values along with the content. The renderer's job shrinks to "lay out this template using these values", which has three consequences we now rely on:

The cost is a fatter request body and one more schema to keep honest. Both were worth it.

tokens roles · scale · spacing mobile client consumes tokens directly API resolves the profile render request rendering service { content, resolved tokens } stateless · knows no themes check:theme build fails on a raw #hex
Tokens flow one way. The renderer receives values, never a theme name – which is what keeps it reproducible.

The check that made it stick

A shared token set does not survive on good intentions. Under deadline someone writes #1F2933 directly into a component, review does not catch it, and six months later the dark theme has a patch of the wrong grey that nobody can trace.

So we made it a build failure. A script walks the component tree looking for raw colour literals outside the token definitions and exits non-zero when it finds one:

npm run check:theme   # fails the build on a hard-coded colour
npm run typecheck     # tsc --noEmit
npm run lint          # eslint . --max-warnings 0

It is perhaps thirty lines of Node. It has caught more theme regressions than our test suite has, because it catches the class of mistake rather than an instance of it. The failure message names the file and the offending literal, so the fix takes about as long as reading it.

What we would do differently

We introduced the token contract after both runtimes already existed, which meant migrating ten templates onto it in one pass. That worked, but only because each template was small and declarative. Had the templates been ordinary web pages with arbitrary CSS, the same migration would have been a rewrite.

The lesson generalises: a shared design system is only cheap to enforce if the things consuming it are constrained enough to be checked mechanically. Build the constraint first, the tokens second.

← All notes