Design tokens : la grammaire de votre Design System

Dans la plupart des applications, changer une chose aussi simple qu’une couleur ou la taille d’un espacement peut devenir un cauchemar, vous obligeant à repasser sur tous vos composants. Pourquoi ? Peut-être parce que vous n’utilisez pas de tokens, ou que ces derniers sont mal architecturés. Oui, n’en déplaise aux esprits moqueurs, le CSS requiert, comme tout, une immense rigueur.
Les tokens sont la base d’un Design System, bien avant les composants. Chaque token constitue un choix unitaire : couleur, espacement, typographie, etc. Ces tokens sont ensuite utilisés pour déterminer le style des composants du Design System, avant que ces composants soient à leur tour utilisés dans diverses applications.
Voyons cela en détail.
Étape 1 : définition des tokens
Ici, il n’est pas question de code, puisque c’est un pur travail de conception graphique. On définit généralement 2 ou 3 couches de tokens. Chacune définit de nouveaux tokens et s’appuie sur les tokens de la couche inférieure, s’il y en a une.
Par souci pédagogique, nous prendrons pour exemple la durée des animations, une dimension simple à traiter. Ce qui suit s’applique à l’identique aux couleurs, aux espacements et aux typographies.
-
Couche primitives : dans notre exemple, ce sont les tokens
--primitive-duration-100: 100;à--primitive-duration-1000: 1000;, dont les valeurs constituent une suite arithmétique allant de 100 à 1000. À ce stade, l’objectif est de limiter le nombre de valeurs possibles (10 contre une infinité) et d’en fixer les écarts relatifs (suite arithmétique, géométrique, etc.). La couche primitives joue donc un rôle essentiel dans l’accessibilité (les contrastes). En revanche, les valeurs n’ont pas d’unité – la couche système se chargera de préciser l’unité selon l’usage qui en sera fait. -
Couche système : les tokens qu’elle définit nous renseignent sur l’usage qui en sera fait au sein des composants. C’est donc une couche sémantique, qui introduit par ailleurs des unités. Exemple :
--system-transition-duration-fast: calc(var(--primitive-duration-300) * 1ms);. Beaucoup de choses se passent sous nos yeux : 1. Une nouvelle restriction, on passe de 10 à seulement 2 valeurs possibles ; 2. L’introduction d’une sémantique slow/fast et d’une unité (millisecondes), qui participe de cette sémantique ; 3. L’utilisation de tokens de la couche primitives, la couche système ne devant pas définir elle-même de valeur brute. -
Couche composants : cette couche est optionnelle, car les tokens de la couche système peuvent être utilisés directement dans les composants, sans ambiguïté. Mais certaines organisations font le choix de définir des tokens tels que
--component-button-transition-duration-fast: var(--system-transition-duration-fast);, c’est-à-dire, pour chaque composant, des tokens à usage unique, puisque préfixés par le nom du composant. Cela me paraît d’un intérêt contestable, voire aller à l’encontre du systématisme de la couche inférieure. En revanche, quelques tokens de cette nature sont parfois utiles pour signaler explicitement un couplage entre plusieurs composants (exemple typique :--component-height-header).
La couche primitives définit bon nombre de tokens que la couche système n’utilise pas (8 sur 10 dans cet exemple). Est-ce du gaspillage ? Non. Les tokens non utilisés définissent l’univers des possibles, l’ensemble fermé des valeurs sur lesquelles la couche système peut s’appuyer. Ces valeurs entretiennent un rapport bien particulier entre elles, que le choix d’une valeur arbitraire ne peut que casser. Le sujet est donc le changement : si la valeur --primitive-duration-300 n’est plus souhaitée, on ne prend pas pour autant --primitive-duration-350. Cette démarche va volontairement à l’encontre du principe YAGNI (You Aren’t Gonna Need It), parce c’est un gage de cohérence.
Par ailleurs, le nommage des tokens est d’une importance particulière. Prenons l’exemple des familles de polices : --primitive-font-family-serif convient pour un token de la couche primitive, parce que le nom reflète la valeur. --system-font-family-title convient pour un token de la couche système, parce que le nom reflète l’usage qui en est fait dans divers composants (<H1 />, <H2 />, mais pas dans un composant spécifique).
/* ------------------------ */
/* 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);
Par contraste, les noms suivants seraient problématiques :
--system-font-family-serif: un token de la couche système ne doit pas décrire sa valeur, puisqu’elle pourrait changer ;--system-title-font-family: ici, il ne s’agit pas de la police utilisée pour les titres en général, mais en fait de la police utilisée dans le composant<Title />. C’est donc en fait un token de la couche composants, n’en voulons pas a priori.
Cet exemple ne présente aucune ambiguïté, mais d’autres dimensions amènent parfois des questions… philosophiques 😱
Définir ces tokens constitue de loin l’étape la plus structurante et la plus difficile. Dans les faits, la démarche est rarement parfaitement ascendante (couche primitive → système → composants). Les allers-retours sont fréquents, et l’on part parfois de maquettes existantes pour reconstruire un Design System et apporter de la rationalité.
Une fois ce travail effectué, il s’agit de traduire les tokens en code utilisable par les composants.
Étape 2 : traduction des tokens en variables CSS
Je trouve commode de définir les tokens directement en CSS (même pour un designer), parce que cela permet d’expliciter les formules de calcul. Mais certaines personnes préfèrent les définir dans un outil de design tel que Figma. Quoi qu’il en soit, il faut, à un moment ou un autre, les porter en CSS, et du CSS pur : aucune librairie, aucun framework CSS à ce stade.
Récapitulons :
: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);
}
J’évoquais précédemment des formules de calcul. En voici 2 exemples :
- Définition d’une échelle de tailles basées sur des puissances de 2 :
/* ------------------------ */
/* 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));
- Définition d’une échelle typographique fluide (mon but n’est pas d’expliquer les calculs mais plutôt de montrer que cette complexité justifie la formalisation d’une couche système, pour le lecteur qui en douterait) :
/* ------------------------ */
/* 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)
);
Étape 3 : implémentation d’une fonction token()
L’étape suivante est celle où les tokens de la couche système sont utilisés en face de propriétés CSS. Nous voulons pouvoir invoquer ces propriétés depuis n’importe quel composant du Design System tout en masquant cette complexité. Pour cela, nous définissons une fonction token appelable comme suit :
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>
);
Pour l’implémentation de cette fonction, vous pouvez utiliser du vanilla CSS – quelque chose comme { transition-duration: var(--system-transition-duration-fast) }, ou bien faire appel à un framework CSS tel que Tailwind. Vous écrirez alors :
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];
C’est à cet endroit précis de l’implémentation de la couche système que sont classiquement pris en charge les états tels que :hover ainsi que le dark mode, si vous souhaitez en implémenter un :
const colors = {
"system-border-color-brand":
"border-(--system-border-color-brand-light) dark:border-(--system-border-color-brand-dark)",
} satisfies AbstractToken;
L’intérêt est que vos composants peuvent invoquer simplement token("system-border-color-brand") sans avoir à se soucier du dark mode, ce dernier est entièrement géré en amont !
Un point important : ici, nous n’utilisons pas la possibilité que nous offre Tailwind de personnaliser un thème. C’est volontaire : nous ne souhaitons pas générer automatiquement toutes les classes utilitaires de Tailwind, étant au contraire dans l’optique de réduire le champ des possibles pour définir un « lexique » utilisable par les composants.
Enfin, le jour où votre framework CSS ne vous satisfait plus, le changement sera relativement facile puisque l’essentiel du changement portera sur l’implémentation de la fonction token.
Étape 4 : utilisation des tokens dans les composants du Design System
Tout est en place pour pouvoir utiliser les tokens au sein des composants du Design System.
À cet instant, il me paraît important de souligner le fait qu’aucun token, et a fortiori aucune classe ou propriété CSS ne doit « sortir » du Design System. Vos applications se contenteront d’assembler les composants prédéfinis du Design System pour former des écrans de haut niveau, des écrans dont la patte visuelle est immédiatement reconnaissable et au sein desquels se retrouvent systématiquement les mêmes patterns graphiques, ce que nous désignons sous le terme de « grammaire visuelle ». Ce systématisme est la clé d’une prise en main toujours plus aisée et agréable pour l’utilisateur qui, pour autant, ne le remarque pas nécessairement.
Le fait que les tokens ne puissent être utilisés qu’au sein du Design System est une contrainte forte. Elle suppose un bon linter, mais surtout que le Design System fournisse les composants nécessaires au positionnement, allant de l’atome au template, de la simple Stack à un PageLayout complet. Un effort substantiel, mais pour un bénéfice réel : le changement graphique, s’il doit avoir lieu, est concentré en un seul endroit, le Design System.
Comme souvent, avant de penser « outil », vous gagnerez à penser « architecture ». Peu importe que vous utilisiez Tailwind ou l’un de ses concurrents tant que l’architecture des tokens est en place.
Un Design System est un réel investissement, qui peut prendre des semaines de travail. Mais le ROI est réel : une fois mis en place, le coût du changement est indépendant de la taille de l’application. À tel point que travailler en marque blanche signifie simplement substituer un fichier CSS à un autre, aucun composant n’a besoin d’être modifié.