❊

Dynamic tokens

How I built Vouchsafe's token system

TL;DR

Vouchsafe’s UI ran on an 80+ color token palette across three different formats. Engineers and agents would both apply these tokens inconsistently.

I replaced them with 50 tokens in a single format, generated from a handful of core colours: you can read my technical write-up on Medium.

I then applied this colour token set across our 300+ components. I was also able to delete every outdated token from the codebase, leaving a clear set of tokens for both humans and agents to use.


Problem

In my first year at Vouchsafe, we iterated our frontend at least twice. Each time we did we took a different approach to tokens. The result was a mix of SCSS, TailwindCSS defaults, and custom theme tokens.

None of these systems had the rules of use clearly defined. What was the difference between grey-400 and grey-500? Why was our green-600 now closer to blue than green? If .vs-primary is deprecated, why is it still being used in new components?

Agent generated frontend changes made this harder to manage; without context it was guessing what it should use and often getting it wrong.

The solution here was not to create yet another palette, but a single system that would be clearer for both humans and agents to understand.

Approach

The core tokens stem from a single OKLCH value. I then used calc() and clamp() to calculate generic UI color tokens. Things like foreground, actions, backgrounds and surfaces.

I chose OKLCH over HSL because its lightnesses match how people actually see colours. In HSL, two colours with identical lightness can have very different contrast:

hsl(200 60% 35%)
oklch(0.43 0.13 200)

This made it easier to adjust the colours with formulas, instead of manually finding the right colours to contrast with each other. You can read the full technical write-up on Medium.

As well as the core colours, I also needed to add:


  • color-cta-primaryoklch(from primary clamp(0, l - 0.15, 0.55) c h)
  • color-on-cta-primaryalways white
  • color-fgoklch(from primary 0.25 c * 0.3 h)
  • color-fg-mutedcolor-fg at 65% opacity
  • color-cta-secondaryoklch(from primary 0.94 c * 0.4 h)
  • color-on-cta-secondaryoklch(from primary 0.35 c h)
  • color-errorfixed: oklch(0.5 0.2 27)
  • color-error-bgfixed: oklch(0.95 0.03 25)
This is an example header

And this is an example description, in a smaller lighter text.

One other change I made was to introduce named tokens. This made it clearer what each token should be used for; the color-border token, for example, was now the default option for if you needed a border on something.

One trade off; older browser support

OKLCH is supported by >95% of browsers. To keep our colours working for older browser versions I created a static CSS file of equivalent hex codes. These are kept in sync manually. This way we maintain support while still taking advantage of a modern colour system in the app be default.

Impact

By the end of this project I had:

Vouchsafe now has a single system with semantic tokens. This means that it’s no longer ambiguous which grey token to use for a border… now the only choice is color-border. Anything you want to be the primary brand colour? color-primary.

Whitelabelling

Our user-facing UI was used within a customer’s own journey. Right now the only option they have is to use our default purple branding.

With a dynamic palette, letting a customer add their own theme would be very simple; you would only need to change the core colour and the UI all rethemes in a way that doesn’t break it.

I only got to prototype this whilst at Vouchsafe, but the concept works like this:

A GIF showing the UI changing colours as I select different colours on the picker.

One small trade-off; the formula for buttons currently caps how light they can get. If you chose a pale pink your buttons would end up a darker shade of pink. To fix this I would have updated the formulas to:

  1. Switch the text from white to black depending on the background
  2. Adjusted for middling colours (colours on which neither white not black would be readable)

Reflections

If I were to do this again, I would design for dark mode from the start. The tokens support it, but adding it now means retesting all of the components. Building this in at the beginning would have been much cheaper!