
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
.

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 (<buttonclass={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:

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"><AdButtonvariant="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 elementsisCustomElement: (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-buttonvariant="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

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-buttonvariant="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

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.