Un Design System d’entreprise avec Stencil.js

Illustration de design system avec Stencil

Lors de mes différentes expériences professionnelles, j'ai souvent constaté un besoin récurrent chez les entreprises disposant de multiples applications développées avec des technologies variées, telles qu'Angular, Vue ou React.

Ces entreprises cherchent à construire un Design System, accompagné des bibliothèques de composants associées, pour unifier l’expérience utilisateur à travers leurs applications en production. L'objectif est de garantir une cohérence visuelle et fonctionnelle, tout en optimisant la collaboration entre les équipes produit, design et développement.

TL;DR:

Dans cet article, j'explore l'utilisation de Stencil.js pour créer un Design System multi-framework (React, Vue, Angular) avec des Web Components. Malgré ses atouts (interopérabilité et design tokens centralisés), Stencil présente des limitations, notamment sur le typage TypeScript et l'intégration dans certains frameworks.

Je partage également une implémentation concrète avec Figma, des scripts de design tokens, et des wrappers pour chaque framework. Bien que prometteur, Stencil n’est pas toujours la solution idéale pour un Design System, et d'autres approches peuvent être plus adaptées selon les besoins. Code source 👉 Stencil Design System Repository


Qu'est-ce qu'un Design System ?

Un Design System est un ensemble cohérent de principes, outils et ressources qui vise à normaliser et à faciliter la conception et le développement de produits numériques. Il s’adresse à toutes les parties prenantes des équipes produit – designers, product managers et développeurs – pour encourager une collaboration efficace et garantir une expérience utilisateur homogène tout en offrant la flexibilité nécessaire pour s’adapter aux besoins spécifiques des projets.

Cette flexibilité repose sur des principes fondamentaux tels que:

  • L’Open-Closed Principle: permettre des extensions et adaptations sans modifier les éléments de base (Open-Closed Principle in React)

  • La composabilité: garantir que les différents éléments du système peuvent être combinés et réutilisés pour répondre à des cas d’usage variés.

Il se compose généralement de trois grandes dimensions:

  • 1. Normalisation du design:

    • Se concentre sur l’aspect visuel et les interactions dans les interfaces.

    • Fournit des directives pour garantir une expérience utilisateur uniforme à travers toutes les interfaces.

    • Inclut des composants visuels réutilisables, des règles d’interaction cohérentes, des principes de conception éprouvés, et des standards pour les éléments comme la typographie, les couleurs, ou les animations.

  • 2. Spécification métier:

    • Définit les règles et contraintes spécifiques liées aux besoins de l’organisation ou du produit.

    • Aligne les objectifs métiers et techniques en traduisant les impératifs fonctionnels (par exemple, conformité réglementaire, préférences spécifiques à chaque marché ou région, ou exigences locales en matière de performance et de contenu)

    • Sert de pont entre les objectifs métier, le design et la technologie, facilitant une collaboration fluide entre les différentes parties prenantes.

  • 3. Ressources et outils (assets):

    • Comprend des maquettes, des composants UI, des règles typographiques, des palettes de couleurs, et des design tokens (variables qui garantissent la cohérence du design dans le code)

    • S'accompagne idéalement d'une documentation accessible et maintenable, décrivant les bonnes pratiques et les cas d’usage.


Besoins techniques pour un Design System

En tant que développeur, j’ai identifié une liste de besoins techniques essentiels pour développer les composants associés à ce système:

  • Support de TypeScript

  • Gestion efficace des design tokens

  • Support de Storybook pour implémenter, documenter et tester visuellement les composants

  • Intégration transparente dans des applications utilisant Angular, Vue ou React

En explorant les différentes options techniques disponibles, une solution revient régulièrement: Stencil.

Pourquoi Stencil ?

Stencil est un compilateur qui génère des Web Components tout en offrant un support natif pour les frameworks Angular, Ember, Vue et React. Cela en fait une solution prometteuse et plébiscité pour la création de bibliothèques de composants réutilisables d'un Design System.

Dans la suite de cet article, nous allons explorer la création d’un Design System basé sur Stencil et évaluer son aptitude à répondre aux besoins identifiés.

Implementation

Design

Pour cette étude, nous avons conçu un design minimaliste sur Figma avec deux composants: Button et Link.

Une maquette Figma simple avec utilisation du plugin Token Studio

Ces composants utilisent des design tokens pour définir les couleurs. Grâce à Tokens Studio for Figma, nous avons exporté directement les tokens vers la base de code, faisant de Figma la source de vérité pour le Design System.

Code

Intégration des design tokens

Pour intégrer les design tokens dans nos composants, nous avons développé un script simple transformant les tokens en variables CSS:

generate-css-variables-from-design-tokens.ts

import fs from 'node:fs';
import { resolve } from 'node:path';
import { DESIGN_TOKENS } from '../src/styles/design.tokens';
const cssVariables = Object.entries(DESIGN_TOKENS)
.map(([colorname, values]) => ` --color-${colorname}: ${values.value};`)
.join('\n');
const cssVariablesInRoot = `:root {\n${cssVariables}\n}`;
fs.writeFileSync(resolve(__dirname, '../src/styles/design-tokens.css'), cssVariablesInRoot);

Ce script génère un fichier CSS contenant toutes les variables nécessaires, rendant les couleurs facilement accessibles dans nos composants.

design-tokens.css

:root {
--color-primary: #6283c4;
--color-primary-hover: #5673ac;
--color-text: #f0f0f0;
--color-secondary: #565656;
--color-secondary-hover: #454545;
--color-secondary-text: #e0e0e0;
--color-light: #c9d9fb;
--color-link: #4f7dc7;
}

Création de la bibliothèque de composants avec Stencil

Nous avons implémenté notre bibliothèque de composants Stencil dans un monorepo avec pnpm. Cependant, une première difficulté est apparue: la configuration de Storybook pour Stencil n’est pas aussi intuitive que pour React, rendant l’expérience développeur perfectible.

En particulier, la documentation manque de clarté sur les étapes nécessaires pour configurer Storybook avec Stencil. De plus, il n’est pas possible d’écrire directement des stories en Stencil, ce qui oblige à utiliser une approche basée sur lit-html. Bien que fonctionnelle, cette contrainte ajoute une complexité supplémentaire, car elle demande aux développeurs de maîtriser un outil additionnel qui n’est pas natif à Stencil.

Voici l'implémentation de notre composant Button avec Stencil

ad-button.component.tsx

import { Component, Event, Prop, h, EventEmitter } from '@stencil/core';
import { AdButtonSizeType, AdButtonVariantType } from './ad-button.model';
@Component({
tag: 'ad-button',
styleUrl: 'ad-button.css',
shadow: true,
})
export class AdButton {
@Prop() label!: string;
@Prop() variant!: AdButtonVariantType;
@Prop() type: 'button' | 'submit' | 'reset' = 'button';
@Prop() disabled: boolean = false;
@Prop() size: AdButtonSizeType = 'medium';
@Event() pressAndHold: EventEmitter<void>;
private holdTimeout: number | undefined;
private handleMouseDown = () => {
this.holdTimeout = window.setTimeout(() => {
this.pressAndHold.emit();
}, 500);
};
private handleMouseUp = () => {
clearTimeout(this.holdTimeout);
};
private getClassName() {
return `button ${this.variant} ${this.size}`;
}
render() {
return (
<button
class={this.getClassName()}
type={this.type}
disabled={this.disabled}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
onMouseLeave={this.handleMouseUp}
>
{this.label}
</button>
);
}
}

Création des stories avec Storybook

Des exemples de stories pour le composant Button:

ad-button.stories.tsx

import { fn } from '@storybook/test';
import { html } from 'lit';
import { Meta, StoryObj } from '@storybook/web-components';
import { type AdButton } from './ad-button.component.js';
import { AD_BUTTON_SIZES, AD_BUTTON_VARIANTS } from './ad-button.model.js';
export default {
title: 'Components/AdButton',
component: 'ad-button',
argTypes: {
variant: {
control: 'select',
options: AD_BUTTON_VARIANTS,
},
size: {
control: 'select',
options: AD_BUTTON_SIZES,
},
},
args: {
onclick: fn(),
},
} as Meta;
type AdButtonStoryType = StoryObj<AdButton>;
export const Examples = {
render: () => html`<div>
<h1>Button Examples</h1>
<div style="display: flex; flex-direction:column; gap: 1rem;">
<ad-button label="Primary Button" variant="primary" size="large"></ad-button>
<ad-button label="Secondary Button" variant="secondary" size="large"></ad-button>
<ad-button label="Tertiary Button" variant="alternate" size="large"></ad-button>
<ad-button label="Disabled Button" variant="primary" disabled></ad-button>
<ad-button label="Small Button" variant="primary" size="small"></ad-button>
</div>
</div>`,
};
export const Primary: AdButtonStoryType = {
args: {
label: 'Primary Button',
variant: 'primary',
size: 'large',
},
};
export const Secondary: AdButtonStoryType = {
args: {
label: 'Secondary Button',
variant: 'secondary',
size: 'large',
},
};
export const Alternate: AdButtonStoryType = {
args: {
label: 'Tertiary Button',
variant: 'alternate',
size: 'large',
},
};
// ...

Après avoir configuré Storybook et intégré nos deux composants (Button et Link), voici un aperçu du résultat:

Une capture d'écran d'un exemple de Storybook

Framework wrappers

Une fois nos composants Stencil créé, l'étape suivante consiste à les rendre utilisables dans nos frameworks cibles: Angular, Vue, et React. Nous allons évaluer ici si Stencil permet une adoption fluide dans un écosystème d'applications existantes.

Wrapper React

Intégration

Pour intégrer les composants Stencil dans une application React, nous devons configurer Stencil pour générer une librairie spécifique à React. Cela se fait en ajoutant un nouvel outputTarget dans la configuration du compilateur Stencil. Le compilateur Stencil va ainsi générer du code dans un package dédié de notre monorepo. Voici comment procéder:

stencil.config.ts

import { Config } from '@stencil/core';
import { reactOutputTarget } from '@stencil/react-output-target';
export const config: Config = {
outputTargets: [
// ...
reactOutputTarget({
outDir: '../react-components/src/lib',
esModules: true,
stencilPackageName: '@agado/components',
}),
// ...
],
// ...
};

Une fois cette configuration ajoutée et la librairie React buildée (dans notre cas, nous utilisons Vite). Nous pouvons utiliser nos composants Stencil comme des composants React. Voici un exemple:

App.tsx

import "@agado/components/design-tokens.css";
import { AdButton, AdLink } from "@agado/react";
function App() {
function handleLongPress(_event: CustomEvent<void>) {
console.log("Button pressed and held");
}
return (
<>
<div className="card">
<AdButton
variant="primary"
label="Click me"
onClick={() => console.log("Button clicked")}
onPressAndHold={handleLongPress}
></AdButton>
</div>
<div className="card">
<AdLink href="https://agado.dev" text="Agado" type="external"></AdLink>
</div>
</>
);
}

⚠️ Notez bien la nécessité d'importer le fichier contenant les variables CSS import "@agado/components/design-tokens.css" (générées à partir des design tokens) dans l'application React. Sans cela, les styles définis dans votre Design System ne seront pas appliqués correctement.

Analyse

L’intégration des composants dans une application React TypeScript révèle une limitation importante: les propriétés obligatoires définies dans les composants Stencil deviennent facultatives dans les composants React générés.

Cela est dû à l’utilisation de @lit/react dans Stencil, qui rends toutes les props facultatives, peu importe leur définition dans Stencil. Voici une issue GitHub qui détaille ce problème: lit/lit#4219


Wrapper Vue

Comme pour React, Stencil permet de générer une librairie spécifique pour Vue grâce à un outputTarget dédié. Voici la configuration nécessaire:

stencil.config.ts

import { Config } from '@stencil/core';
import { vueOutputTarget } from '@stencil/vue-output-target';
export const config: Config = {
outputTargets: [
// ...
vueOutputTarget({
proxiesFile: '../vue-components/src/vue.components.ts',
componentCorePackage: '@agado/components',
includeImportCustomElements: true,
}),
// ...
],
// ...
};

Une fois la librairie Vue compilée (toujours avec Vite dans notre cas), les composants Stencil peuvent être utilisés dans une application Vue+TypeScript. Il est cependant nécessaire de définir dans la configuration Vite, les tags qui seront traités comme des custom elements. Sans ça, les custom events ne fonctionnent pas

vite.config.ts

export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// treat all tags with a "ad-" prefix as custom elements
isCustomElement: (tag) => tag.startsWith("ad-"),
},
},
}),
],
});

Voici un exemple d’intégration:

App.vue

<script setup lang="ts">
import { AdButton, AdLink } from "@agado/vue";
import "@agado/components/design-tokens.css";
function handleLongPress(_event: AdButtonCustomEvent<void>) {
console.log("Button pressed and held");
}
</script>
<template>
<main>
<ad-button
variant="primary"
label="Click me"
@click="() => console.log('Button clicked')"
@pressAndHold="handleLongPress"
></ad-button>
<ad-link href="https://agado.dev" text="Agado" type="external"></ad-link>
</main>
</template>

Analyse

L’intégration Vue se distingue par un meilleur support des props obligatoires comparé à React. Les propriétés définies comme required dans les composants Stencil sont correctement signalées comme obligatoires dans les composants Vue

Erreur de compilatoin lorsqu'il manque une prop

Wrapper Angular

Terminons avec l'intégration de nos composants Stencil en Angular. L’intégration des composants Stencil dans Angular nécessite également la configuration d’un outputTarget spécifique. Voici comment ajouter cette étape dans votre configuration Stencil:

stencil.config.ts

import { Config } from '@stencil/core';
import { angularOutputTarget } from '@stencil/angular-output-target';
export const config: Config = {
outputTargets: [
// ...
angularOutputTarget({
componentCorePackage: '@agado/components',
outputType: 'standalone',
directivesProxyFile: '../angular-components/projects/components/src/lib/proxies.ts',
directivesArrayFile: '../angular-components/projects/components/src/lib/index.ts',
}),
// ...
],
// ...
};

À ce stade, tout semble simple, mais c’est là que les choses se compliquent. Cela faisait quelques années que je n’avais pas touché à Angular de manière significative, et intégrer @angular/cli dans un monorepo existant s’est révélé être un véritable casse-tête.

Finalement après quelques heures et notre librairie de composants Angular buildée, voici un exemple d’intégration dans une webapp Angular:

app.component.ts

import { AdButton, AdLink } from '@agado/angular/components';
import { Component } from '@angular/core';
import '@agado/components/design-tokens.css';
@Component({
selector: 'app-root',
imports: [AdButton, AdLink],
template: `
<main class="main">
<ad-button
variant="primary"
label="Click me"
(click)="handleClick()"
(pressAndHold)="handleLongPress($event)"
></ad-button>
<ad-link href="https://agado.dev" text="Agado" type="external"></ad-link>
</main>
`,
})
export class AppComponent {
handleClick() {
console.log('Button clicked');
}
handleLongPress(_event: CustomEvent<void>) {
console.log('Button press and held');
}
}

Analyse

Malheureusement, comme avec React, les props obligatoires deviennent facultatives, ce qui peut entraîner des erreurs si certaines propriétés critiques sont omises. Pire encore, le type des props est perdu lors de la compilation. Cette limitation est documentée dans cette issue GitHub: stencil#2909

Intégration de Tailwind

Lorsqu’il s’agit de construire des Design Systems d’entreprise, j’ai une préférence marquée pour Tailwindcss. Cet outil permet d’enforcer efficacement les contraintes de design grâce à un langage commun qui harmonise les pratiques entre les équipes.

Avec Tailwind, l’intégration de design tokens dans un thème Tailwind est simple et directe. Cela permet de publier un thème réutilisable dans plusieurs applications, tout en garantissant une cohérence visuelle à travers les projets. Voici un exemple de configuration:

import type { Config } from "tailwindcss";
import { DESIGN_TOKENS } from "./design.tokens";
export const designSystemTheme: Config["theme"] = {
extend: {
colors: {
primary: DESIGN_TOKENS.primary.value,
text: DESIGN_TOKENS.text.value,
"secondary-text": DESIGN_TOKENS["secondary-text"].value,
"light-text": DESIGN_TOKENS["light-text"].value,
link: DESIGN_TOKENS.link.value,
secondary: DESIGN_TOKENS.secondary.value,
"primary-hover": DESIGN_TOKENS["primary-hover"].value,
"secondary-hover": DESIGN_TOKENS["secondary-hover"].value,
},
},
};

Ce fichier crée un thème Tailwind basé sur les design tokens

Une fois le thème configuré, l’intégration dans une application est extrêmement simple

import type { Config } from "tailwindcss";
import { designSystemTheme } from "@agado/components/tailwind.config";
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: designSystemTheme,
} satisfies Config;

Grâce à cette configuration, toutes les applications utilisant ce thème bénéficient des mêmes couleurs, typographies, et autres styles définis dans le Design System

Cependant, l’intégration de Tailwind avec les Web Components, et en particulier le shadow DOM, pose des défis importants. Le shadow DOM encapsule les styles pour éviter les conflits globaux, mais cette encapsulation limite également l'application des classes Tailwind, qui reposent sur un CSS global. Pour contourner ce problème, des outils tiers comme le plugin stencil-tailwind-plugin

Malgré cette solution, l’utilisation d’outils tiers pour intégrer Tailwind dans le shadow DOM pose des questions de pérennité et de maintenabilité. Pour ces raisons nous n’avons pas approfondi davantage cette approche et de nous concentrer sur l'utilisation de variables CSS


Conclusion

Vous pouvez retrouver l'ensemble du code source ayant servit à cette étude sur ce repository

Suite à cette étude, que peut-on en conclure ? Reprenons les besoins techniques essentiels définis en introduction pour évaluer l’utilisation de Stencil dans la création de composants pour un Design System d'entreprise:

Critères

React

Vue

Angular

Support de TypeScript

2/5

5/5

1/5

- Observations

Les types sont générés, mais les props obligatoires deviennent facultatives.

Fonctionne parfaitement avec des types correctement appliqués.

Les types sont perdus lors de la compilation: pas de sécurité à la compilation.

Gestion des Design Tokens

3/5

3/5

3/5

- Observations

OK via variables CSS, passable avec Tailwind

Idem React

Idem React

Intégration dans un projet

4/5

3/5

2/5

- Observations

Simple après configuration

Nécessité de déclarer à Vue quels sont les custom elements

Simple, d'autant plus grace à l'utilisation des composants standalone

À la lumière de ces résultats, il apparaît que Stencil n’est pas nécessairement l’outil idéal pour implémenter les composants d’un Design System. Bien que la promesse de Web Components comme socle universel pour des composants multi-framework soit séduisante, la réalité montre que cette approche comporte des limites significatives:

En effet, comme le souligne Ryan Carniato, créateur de Solidjs, les Web Components ne répondent pas aux attentent lorsqu'il s'agit de s'intégrer dans les frameworks front Web Components Are Not the Future. Evan You, créateur de Vue.js, a également exprimé des reserves à ce sujet post

We spent quite some efforts in Vue 3.5 to improve authoring custom elements in Vue - while I believe the end API is providing a decent experience, the internals are… not that pretty. So many workarounds to get the same mental model as Vue components.

Recommandations

Pour les entreprises, il semble plus judicieux d’adopter une approche pragmatique:

Lorsque c'est possible, rester sur un seul framework principal pour développer et maintenir les composants d’un Design System et les applications. Cette stratégie permet une meilleure d'exploiter pleinement chaque framework.

Pour les autres cas, une alternative viable est de centraliser uniquement les design tokens et des outils transversaux. Les composants peuvent ensuite être développés nativement dans chaque framework cible, tout en conservant une cohérence grâce aux tokens.

Bien que Stencil ait ses atouts, cette étude met en évidence les limites des Web Components comme solution universelle pour les Design Systems. Il est essentiel pour les équipes techniques de considérer ces aspects avant de se lancer dans une implémentation basée sur Stencil, et de réfléchir à des solutions mieux adaptées aux besoins spécifiques de leur entreprise.