Créer son propre design system avec Vanilla Extract

Créer son propre design system avec Vanilla Extract

Qu’est-ce que Vanilla Extract ?

Vanilla Extract se présente comme une bibliothèque permettant d’utiliser TypeScript comme préprocesseur CSS. Voilà. ◔_◔

Bon ok, on va essayer de développer un peu…

Vanilla Extract est issu de la famille des outils CSS-in-JS tels que styled-component ou Emotion à ceci près que Vanilla Extract est, comme sa propre présentation l’indique, orienté TypeScript.

Le CSS-in-JS

Si je devais présenter CSS-in-JS, je dirais que c’est une approche de stylisation des interfaces web qui consiste à écrire du CSS directement dans du JavaScript. Contrairement aux méthodes traditionnelles où les styles sont définis dans des fichiers .css séparés, le CSS-in-JS permet de définir, appliquer et manipuler les styles depuis le code JavaScript. (Chose étonnante, il se trouve que Chat-GPT a exactement la même définition que moi ! ¯\_(ツ)_/¯).

Cela présente un certains nombre d’avantages concernant l’aspect « scopé » du css, la facilité pour rendre les classes dynamiques, la possibilité de faire cohabiter le js et le css dans chaque composant…

Pourquoi Vanilla Extract ?

Les outils CSS-in-JS présentent également un certain nombre désavantages (liste non exhaustive) :

  • problème de performances lorsque le css est compilé au run time dans le navigateur (ce qui est le cas de la plupart des outils CSS-in-JS)
  • fichiers JS plus importants
  • courbe d’apprentissage plus « raide » pour un intégrateur qui ne connaîtrait pas JS

Si Vanilla-Extract ne solutionne pas le dernier point évoqué, il permet d’éviter les deux premiers : Vanilla-Extract va générer les fichiers au build time. Cela change tout : on garde les avantage du css-in-js (sauf la cohabitation du js et du css, on y reviendra…) en se débarrassant de certains de ses inconvénients !
Dernier petit Bonus, avec Vanilla-Extract, on bénéficie dans notre IDE, out-of-the-box, de deux gros avantages de typescript : le typechecking et l’autocomplétion !

Utiliser Vanilla Extract

Créer et appliquer un style

Vanilla Extract nous propose un ensemble d’API qui va nous permettre de générer le CSS. Comme expliqué dans la doc Vanilla Extract, l’API style, par exemple, va nous permettre de générer une classe CSS et de lui associé des règles.

Imaginons que nous voulions gérer le style d’un composant Header (Header.tsx). Dans un fichier Header.css.ts, on va écrire le code suivant :

//Header.css.ts
import { style } from '@vanilla-extract/css';

export const headerMenu = style({
  backgroundColor: "white",
  color: "black",
});

…qui va générer et exporter la classe suivante :

.Header_headerMenu__lmf3270 {
  color: black;
  background-color: white;
}

… et que l’on pourra appliquer ainsi dans notre composant Header (Header.tsx) :

// Header.tsx
const Header: React.FC = () => {
   ...
   return (
     <header>
       <nav className={headerMenu}>
       ...
       </nav>
     </header>
   );
};

Il faut prendre en compte de petits changements en termes de syntaxe, mais jusqu’ici, rien de bien compliqué, surtout si on s’appuie sur la très bonne doc de VE.

Créer un thème

Imaginons maintenant que nous voulions un site qui possède un thème light et un thème dark. Il me faut remplacer les valeur en dur du CSS par des variables. Avec Vanilla Extract ca sera très simple :

On va crée un contrat de thème en utilisant l’api createThemeContract. Un contrat de thème c’est simplement la déclaration de toutes les variables (sans valeurs) qui seront utilisées dans un thème. Ce contrat sera ensuite fourni à chaque thème. Vanilla Extract s’assurera ensuite que chaque thème respecte scrupuleusement ce contrat. Si on reprend l’exemple ci-dessus, ca nous donnerait quelque chose comme ceci :

//themeContract.css.ts
import { createThemeContract } from "@vanilla-extract/css";
export const themeVars = createThemeContract({
  header: {
    background: {
      color: null,
    },
    font: {
      color: null,
    },
  }

//themeLight.css.ts
import { createTheme } from "@vanilla-extract/css";
export const themeLight = createTheme(themeVars, {
  header: {
    background: {
      color: "white",
    },
    font: {
      color: "black",
    },
  }
});

//themeDark.css.ts
import { createTheme } from "@vanilla-extract/css";
export const themeDark = createTheme(themeVars, {
  header: {
    background: {
      color: "black",
    },
    font: {
      color: "white",
    },
  }
});

On utilise ensuite le variables de notre contrat de thème dans le fichier CSS du composant :

//Header.css.ts 
export const headerMenu = style({
  color: themeVars.header.font.color,
  backgroundColor: themeVars.header.background.color
});

Enfin, il ne nous reste plus qu’à appliquer notre thème comme un style sur un élément parent :

// Layout.tsx
const Layout: React.FC = () => {
   ...
   return (
      <div className={themeDark}>
        <header>
          <nav className={headerMenu}>
            ...
           </nav>
        </header>
      </div>
   );
};

Créer ses propres classes utilitaires avec Vanilla Extract et Sp'(rinkles

Pour créer son propre design system, VE nous propose un package optionnel : SPRINKLES

Sprinkles va nous permettre de définir des propriétés et des valeurs possibles pour ces propriétés, de combiner plusieurs propriétés et configurer des conditions.

Comme vous pouvez le voir dans la doc, une fois que l’on a configuré le fichier sprinkles.css.ts qui va bien, on peut utiliser la fonction sprinkles()de cette manière :

//styles.css.ts
import { sprinkles } from './sprinkles.css.ts';

export const container = sprinkles({
  display: 'flex',
  //on a préalablement défini dans sprinkles.css.ts que 'paddingX' = paddingLeft + paddingRight :
  paddingX: 'small' // 'small' fait partie des valeurs possibles définies également dans sprinkles.css.ts 
  // Les règles conditionnelles en fonction du support (mobile ou ordinateur) et du thème (light ou dark) :
  flexDirection: {
    mobile: 'column',
    desktop: 'row'
  },
  background: {
    lightMode: 'blue-50',
    darkMode: 'gray-700'
  }
});

C’est un peu sympa, mais c’est un peu verbeux, surtout si on doit combiner les fonctions sprinkles() et style() :

export const container = style([
  sprinkles({
    display: 'flex',
    padding: 'small'
  }),
  {
    ':hover': {
      outline: '2px solid currentColor'
    }
  }
]);

Là où ça devient vraiment cool, c’est quand on se crée un petit…

Un composant primitif polymorphe

Derrière cette expression qui vous permettra d’avoir l’air intelligent, se cache un principe assez simple : créer un composant générique qui pourra représenter n’importe quel élément html (ici via la prop react « as »), qui pourra accepter n’importe quelle prop native HTML mais aussi les props utilisant les propriétés que l’on vient de définir grâce à Sprinkles.
Par convention, nous nommerons ce composant « Box » 📦.

Maintenant la question que tout le monde se pose :

Gif de Brad Pitt dans le film Serven, hurlant "What's in the fucking box ?"
Pour les plus jeunes et les incultes, c’est pas juste un type random qui dit un gros mot hein ! C’est Brad Pitt dans Seven

Eh bien dans la boite, il y a ceci :

//Box.tsx
import React from "react";
import clsx from "clsx";
import { sprinkles, Sprinkles } from "../styles/sprinkles.css";

export type BoxProps = React.PropsWithChildren &
  Sprinkles &
  Omit<React.AllHTMLAttributes<HTMLElement>, "color"> & {
    as?: React.ElementType;
  };

export const Box = React.forwardRef<unknown, BoxProps>(({ as = "div", className, ...props }, ref) => {
  const sprinklesProps: Record<string, unknown> = {};
  const nativeProps: Record<string, unknown> = {};

  Object.entries(props).forEach(([key, value]) => {
    if (sprinkles.properties.has(key as keyof Sprinkles)) {
      sprinklesProps[key] = value;
    } else {
      nativeProps[key] = value;
    }
  });

  return React.createElement(as, {
    className: clsx([sprinkles(sprinklesProps), className]),
    ref,
    ...nativeProps,
  });
});

C’est pas bien compliqué mais voyons quand même ce code dans le détail :

Imports

import React from "react";
import clsx from "clsx";
import { sprinkles, Sprinkles } from "../styles/sprinkles.css";
  • React → nécessaire pour créer des composants React.
  • clsx → permet de fusionner facilement plusieurs classes CSS (évite les concatenations manuelles).
  • sprinkles et Sprinkles → viennent probablement d’un système de design basé sur Vanilla Extract

Type des props (BoxProps)

export type BoxProps = React.PropsWithChildren &
  Sprinkles &
  Omit<React.AllHTMLAttributes<HTMLElement>, "color"> & {
    as?: React.ElementType;
  };
  • React.PropsWithChildren → autorise d’avoir des children dans ce composant.
  • Sprinkles → permet de passer toutes les props définies par le système sprinkles (par ex. padding="small", background="blue"…).
  • Omit<React.AllHTMLAttributes<HTMLElement>, "color"> → autorise toutes les props HTML classiques (id, onClick, etc.), sauf color (car déjà gérée par sprinkles).
  • { as?: React.ElementType } → permet de changer le type de balise HTML (par défaut div).

Le composant Box

export const Box = React.forwardRef<unknown, BoxProps>(
  ({ as = "div", className, ...props }, ref) => {
  • React.forwardRef → permet de transmettre une ref au DOM (utile pour manipuler l’élément directement, ex. focus).
  • as = "div" → valeur par défaut si l’utilisateur ne précise pas la balise.
  • className → récupère une éventuelle classe CSS donnée par l’utilisateur.
  • ...props → récupère toutes les autres props (styles, HTML, événements…).

Tri des props

  const sprinklesProps: Record<string, unknown> = {};
  const nativeProps: Record<string, unknown> = {};

  Object.entries(props).forEach(([key, value]) => {
    if (sprinkles.properties.has(key as keyof Sprinkles)) {
      sprinklesProps[key] = value;
    } else {
      nativeProps[key] = value;
    }
  });
  • On sépare les props de style (sprinkles) des props natives HTML.
  • Exemple : <Box padding="large" id="myBox" onClick={...} />
    • padding="large" → va dans sprinklesProps.
    • id="myBox" et onClick={...} → vont dans nativeProps.

Rendu

  return React.createElement(as, {
    className: clsx([sprinkles(sprinklesProps), className]),
    ref,
    ...nativeProps,
  });
});
  • React.createElement(as, {...}) → crée dynamiquement l’élément HTML (div, span, etc.).
  • className: clsx([sprinkles(sprinklesProps), className]) → applique :
  • la classe générée par sprinkles (styles utilitaires)
    • la classe custom passée via className.
  • ref → transmet la référence au DOM.
  • ...nativeProps → applique les autres props HTML normales (id, aria-label, onClick, etc.).

Ca y est, on est tout bon voici par exemple comment on peut désormais l’utiliser :

      <Box
        as={"div"}
        className={"container"}
        display="flex"
        justifyContent="center"
        px={"16"}
        backgroundColor="primary"
      >
        <Box
          as={"h2"}
          className={"title"}
          mb={"8"}
          fontSize={{ mobile: "xxLarge", tablet: "xxLarge", smallDesktop: "large", largeDesktop: "large" }}
        >
          Title
        </Box>
        <Box as={"button"} ml={"64"} backgroundColor="secondary"></Box>
      </Box>

Franchement ? Elle est pas belle la vie ?

Conclusion

Bien évidemment, par sa syntaxe css-in-ts, V-E est un peu déstabilisant au début. Cependant la prise en main est assez rapide (et le sera d’autant plus dans le cadre d’un onboarding).
Le fait d’avoir un le style en typescript ouvre tout un horizon pour rendre nos classes css encore plus dynamiques.
Par ailleurs le cadre assez stricte offert pas Vanilla Extract me semble opportun dans le cadre du travail en équipe. C’est un bon moyen de garder un css bien structuré.