Converging Sources of Design Tokens

Randall A Gordon
Developing Koan
Published in
4 min readNov 23, 2021

Design tokens are meant to be shared — both across teams and across technologies. From Figma to code and back to the screen in DevTools and Storybook, we can all communicate faster when we’re speaking the same language. But token definitions change, and so do the tools consuming them. While handling the churn around such changes can be challenging, it is also an opportunity for incremental improvements.

A `blue-100` color token consistently shared across Figma, code, and DevTools

As Koan’s codebase has evolved over five years, two significant changes have required evolving how we use tokens:

  • We introduced Tailwind! Which meant transforming the token definitions into its configuration format
  • A couple years in, our browser support matrix changed — no more IE11! — so we moved from the PostCSS postcss-simple-vars plugin to CSS Custom Properties

In both cases we kept the churn from a developer experience perspective to a minimum by only using the new approaches in new code and leaving legacy code be. (We also took a similar approach when switching from Flow to TypeScript.)

Let’s take a look at a few details of how our tokens are defined and how those definitions are shared with these changes in mind.

Defining design tokens

We define our tokens in code as plain ol’ JavaScript objects. This allows them to be easily transformed and consumed by various tools throughout our codebase via Tailwind, Custom Properties, or any as-yet-unknown future CSS tooling. To keep things focused, let’s just look at our color palette definitions.

First, lets look at how they’re consumed by Tailwind:

That toTailwindColors utility method is the crux of it. An object containing our colors (of type Record<ColorName, ColorHexValue>), which get used as CSS Custom Properties, has its color- prefixes removed and the hex value swapped for a var() reference to the custom property—easy peasy!

But what do those color definitions actually look like?

The legacy method we were handing off to postcss-simple-vars is just a plain ol' JavaScript object with keys of the color name and values of their CSS hex color code.

Fading away while painting anew

Calling out when we’ve accidentally referred to any of the legacy colors via var() was pretty low effort to set up—we just don't make these available as CSS Custom Properties at the :root! And likewise, the new definitions aren't provided to postcss-simple-vars. Attempting to use legacy definitions with var() or use new definitions as a $ prefixed simple variable just won't apply the color, so usage naturally just…fades away. We considered adding a CI check to call such attempts, but simply not seeing the expected color was obvious enough. We just took the no effort, easy win!

The new setup is a bit more organized and uniform. Though we have more than just these in practice, here are some sample blues, grays, and “spot” color definitions as an example:

The final palette object we'll be referring to from here on is just spreads of the individual groups. In our case, keeping the groups separated on the export end of things allows for easily consuming and displaying them in Storybook.

Evolving design token definitions

From these base definitions, we also derive a semanticPalette with names which are more meaningful than raw color names. Two things we keep in mind:

  • Colors in this section should have semantic meaning. Put another way, they should not have a color in the name, e.g: use color-goal-on-track instead of color-goal-green
  • The colors themselves should be defined using var() references to the colors defined in palette by their CSS Custom Property name. Viewing them in DevTools, we'll see our named colors rather than hex codes, as in the screenshots above.

We also needed a way to use our RGB color definitions with an alpha via rgba(). A common approach is breaking out separate --r, --g, and --b variables to then be able to use them as rgba(var(--r), var(--g), var(--b), 0.5) but we decided to lean on a little bit of transformation:

A color definition like --color-blue-100: #1fa1ba; gets transformed into --color-blue-100-rgb: 31, 161, 186; which can then be leveraged as rgba(var(--color-blue-100-rgb), 0.5) and is much easier to work with!

The same thing occurs with the semantic palette, but it works just a little bit different since those are themselves defined via var()!

With all the colors defined, they then get consumed by a codegen script that converts them to CSS which gets committed (and verified to be current via a CI check):

Between these CSS Custom Properties, the underlying POJO definitions, and the Tailwind config we are able to refer to colors by consistent names no matter which section of the codebase we are working in.

Bonus!

If you happened to look closely at the Tailwind config above, you may have noticed a little easter egg that hasn’t been explained yet — pxToRemSpacings! What's that!? A clever* little trick!

This little bit of clever* code is filling out a Tailwind spacing config from 1 through MAX_SPACINGS_PX that uses pixel values for the class names while the values are all their rem equivalents. We do this since we use rems throughout the codebase with a base font-size of 16px. Thus, p-16 comes out as padding: 1rem! This lets us use the pixel values directly from Figma inspection panels without needing to first convert to rem.

*Did I mention it was clever? 😅 This should be wielded with care as it can introduce a lot of additional Tailwind classes! And as such, make already long purge steps that much longer, especially if MAX_SPACINGS_PX is set too high. In our case, we generate our Tailwind build and commit it, so the once-every-week-or-two that we needed to do so it isn't onerous. If you're running Tailwind in a watch, this is probably going to make it very slow.

--

--