Mathieu Eveillard

Design tokens: the grammar of your Design System

Design tokens: the grammar of your Design System

In most applications, changing something as simple as a color or the size of a spacing can become a nightmare, forcing you to go back and update all your components. Why? Perhaps because you’re not using tokens, or because they’re poorly structured. Yes, despite what skeptics might say, CSS—like everything else—requires immense precision.

Tokens are the foundation of a Design System, long before components. Each token represents a single choice: color, spacing, typography, etc. These tokens are then used to determine the style of the Design System’s components, which are in turn used in various applications.

Let’s take a closer look.

Step 1: Defining tokens

This stage has nothing to do with code, as it’s purely graphic design work. We generally define two or three layers of tokens. Each layer defines base tokens and builds upon the tokens in the layer below it, if there is one.

For educational purposes, we’ll use animation duration as an example—a simple aspect to work with. The following applies equally to colors, spacing, and typography.

The primitives layer defines a number of tokens that the system layer does not use (8 out of 10 in this example). Is this a waste? No. The unused tokens define the universe of possibilities—the closed set of values on which the system layer can rely. These values maintain a very specific relationship with one another, which choosing an arbitrary value would only break. The key point, therefore, is change: if the value --primitive-duration-300 is no longer desired, that doesn’t mean we simply switch to --primitive-duration-350. This approach deliberately goes against the YAGNI (You Aren’t Gonna Need It) principle, because it ensures consistency.

Furthermore, the naming of tokens is of particular importance. Take font families as an example: --primitive-font-family-serif is suitable for a token at the primitive layer, because the name reflects the value. --system-font-family-title is suitable for a token at the system layer, because the name reflects its usage across various components (such as <H1 /> and <H2 />, but not within a specific component).

/* ------------------------ */
/* Primitive tokens         */
/* ------------------------ */

/* Font family */
--primitive-font-family-sans-serif:
  Roboto, Verdana, "Helvetica Neue", sans-serif;
--primitive-font-family-serif: Georgia, serif;
--primitive-font-family-mono: Courrier New, mono;

/* ------------------------ */
/* System tokens            */
/* ------------------------ */

/* Font family */
--system-font-family-base: var(--primitive-font-family-sans-serif);
--system-font-family-title: var(--primitive-font-family-sans-serif);

In contrast, the following names would be problematic:

This example is completely unambiguous, but other aspects sometimes raise… philosophical questions 😱

Defining these tokens is by far the most foundational and challenging step. In practice, the process is rarely a strictly bottom-up approach primitive layer → system → components. There’s frequent back-and-forth, and sometimes we start with existing mockups to rebuild a Design System and bring a sense of rationality to it.

Once this work is done, the next step is to translate the tokens into code that the components can use.

Step 2: Translating tokens into CSS variables

I find it convenient to define tokens directly in CSS (even for a designer) because it makes the calculation formulas explicit. But some people prefer to define them in a design tool like Figma. In any case, at some point, you’ll need to port them to CSS—and pure CSS at that: no libraries or CSS frameworks at this stage.

Let’s recap:

:root {
  /* ------------------------ */
  /* Primitive tokens         */
  /* ------------------------ */

  /* Duration */
  --primitive-duration-100: 100;
  --primitive-duration-200: 200;
  --primitive-duration-300: 300;
  --primitive-duration-400: 400;
  --primitive-duration-500: 500;
  --primitive-duration-600: 600;
  --primitive-duration-700: 700;
  --primitive-duration-800: 800;
  --primitive-duration-900: 900;
  --primitive-duration-1000: 1000;

  /* ------------------------ */
  /* System tokens            */
  /* ------------------------ */

  /* Animations */
  --system-transition-duration-fast: calc(var(--primitive-duration-300) * 1ms);
  --system-transition-duration-slow: calc(var(--primitive-duration-600) * 1ms);
}

I mentioned calculation formulas earlier. Here are two examples:

/* ------------------------ */
/* Primitive tokens         */
/* ------------------------ */

/* Size */
--size-power: 2;
--primitive-size-1: 1;
--primitive-size-2: calc(var(--primitive-size-1) * var(--size-power));
--primitive-size-3: calc(var(--primitive-size-2) * var(--size-power));
--primitive-size-4: calc(var(--primitive-size-3) * var(--size-power));
--primitive-size-5: calc(var(--primitive-size-4) * var(--size-power));
--primitive-size-6: calc(var(--primitive-size-5) * var(--size-power));
--primitive-size-7: calc(var(--primitive-size-6) * var(--size-power));
--primitive-size-8: calc(var(--primitive-size-7) * var(--size-power));
--primitive-size-9: calc(var(--primitive-size-8) * var(--size-power));
--primitive-size-10: calc(var(--primitive-size-9) * var(--size-power));
/* ------------------------ */
/* System tokens            */
/* ------------------------ */

--system-font-size-growth-h1: calc(
  (
      var(--primitive-font-size-desktop-2xl) -
        var(--primitive-font-size-mobile-2xl)
    ) /
    (
      var(--primitive-viewport-growth-stop) -
        var(--primitive-viewport-growth-start)
    )
);
--system-font-size-h1: clamp(
  calc(var(--primitive-font-size-mobile-2xl) / var(--1rem-in-px) * 1rem),
  calc(
    (
        var(--primitive-font-size-mobile-2xl) -
          var(--system-font-size-growth-h1) *
          var(--primitive-viewport-growth-start)
      ) /
      var(--1rem-in-px) * 1rem + var(--system-font-size-growth-h1) * 100vw
  ),
  calc(var(--primitive-font-size-desktop-2xl) / var(--1rem-in-px) * 1rem)
);

Step 3: Implementing a functiontoken()

The next step involves using the tokens from the system layer as CSS properties. We want to be able to call these properties from any component in the Design System while hiding this complexity. To do this, we define a function token that can be called as follows:

import React from "react";
import { token } from "../../../tokens";
import { join } from "../../../utils";

type Props = {
  children: React.ReactNode;
};

export const CallToActionButton: React.FC<Props> = ({ children }) => (
  <button
    className={join([
      // other tokens
      token("system-transition-duration-fast"),
    ])}
  >
    {children}
  </button>
);

To implement this feature, you can use vanilla CSS—something like { transition-duration: var(--system-transition-duration-fast) }—or use a CSS framework such as Tailwind. You would then write:

type AbstractToken = Readonly<{
  [key: string]: string;
}>;

const transitions = {
  "system-transition-duration-fast":
    "duration-(--system-transition-duration-fast)",
  "system-transition-duration-slow":
    "duration-(--system-transition-duration-slow)",
} satisfies AbstractToken;

export const tokens = {
  ...transitions,
  // Other dimensions
} satisfies AbstractToken;

export type Token = keyof typeof tokens;

export const token = (token: Token): string => tokens[token];

It is at this precise point in the implementation of the system layer that states such as:hover, and dark mode—if you wish to implement one—are typically handled:

const colors = {
  "system-border-color-brand":
    "border-(--system-border-color-brand-light) dark:border-(--system-border-color-brand-dark)",
} satisfies AbstractToken;

The benefit is that your components can simply call token("system-border-color-brand") without having to worry about dark mode—it’s all handled upstream!

An important point: here, we’re not using Tailwind’s ability to customize a theme. This is intentional: we don’t want to automatically generate all of Tailwind’s utility classes; rather, we aim to narrow down the range of possibilities to define a “lexicon” that can be used by the components.

Finally, when the day comes that your CSS framework no longer meets your needs, making the switch will be relatively easy since most of the change will involve implementing the token function.

Step 4: Using tokens in Design System components

Everything is now in place to use tokens within the Design System’s components.

At this point, I think it’s important to emphasize that no token—and certainly no CSS class or property—should “leave” the Design System. Your applications will simply assemble the predefined components of the Design System to form high-level screens—screens with an immediately recognizable visual style, in which the same graphic patterns are consistently used—what we refer to as “visual grammar.” This consistency is the key to an increasingly easy and enjoyable user experience—even if the user doesn’t necessarily notice it.

The fact that tokens can only be used within the Design System is a significant constraint. It requires a good linter, but above all, it requires that the Design System provide the necessary components for styling—ranging from atoms to templates, from a simple Stack to a complete PageLayout. It’s a substantial effort, but one that yields real benefits: any visual changes, if they’re needed, are concentrated in a single place—the Design System.

As is often the case, before thinking about “tools,” you’ll be better off thinking about “architecture.” It doesn’t matter whether you use Tailwind or one of its competitors, as long as the token architecture is in place.

A Design System is a real investment that can take weeks of work. But the ROI is real: properly implemented, the cost of making changes is independent of the application’s size. In fact, working in white-label mode simply means replacing one CSS file with another—no components need to be modified.

← All posts