Retour au Blog
UX & Design31 juillet 202611 min de lecture

Construire un design system avec Angular Material

DR

Doug Ribeiro

11 min de lecture

Chez Wonderlabs, j'ai construit et maintenu la bibliothèque de composants Angular de l'équipe par-dessus Angular Material, pour une PWA de carte de crédit servant plus de 50 000 utilisateurs. Storybook n'y est entré qu'un peu, vers la fin. L'objectif était un système qui restait aligné avec la bibliothèque Figma, que nous trois côté front-end pouvions consommer sans combattre l'API, et qui restait cohérent au fil des livraisons. Voici ce qui m'a mordu et ce que je ferais différemment.

Architecture de thème Angular Material 17+

Material Design 3 est arrivé en expérimental dans Angular Material 17.2 puis est devenu stable dans la v18, avec un système de thème basé sur les CSS custom properties construit sur les tokens MD3. Cela a remplacé l'ancienne approche par mixins Sass et c'est une vraie amélioration. Vous définissez un thème avec define-theme, qui produit des CSS custom properties scopées. Les styles de composants consomment ces propriétés. Fini la chasse aux variables Sass profondes quand une couleur doit changer. La bibliothèque Wonderlabs est antérieure à tout ça : elle vivait sur Angular 15 et l'API Sass montrée ci-dessous. La leçon sur les tokens qui suit s'applique aux deux. Les extraits de composants plus bas sont illustratifs et utilisent la syntaxe Angular actuelle (blocs @if, inputs requis) ainsi que les noms de tokens Material actuels ; sur Angular 15, vous écririez *ngIf et de simples @Input().

scss
// styles/_theme.scss
@use '@angular/material' as mat;

$primary-palette: mat.define-palette(mat.$indigo-palette, 700, 400, 900);
$accent-palette: mat.define-palette(mat.$amber-palette, A200);

$light-theme: mat.define-light-theme((
  color: (
    primary: $primary-palette,
    accent: $accent-palette
  ),
  typography: mat.define-typography-config(
    $font-family: 'Inter, sans-serif',
    $headline-4: mat.define-typography-level(2rem, 2.5rem, 700),
    $body-1: mat.define-typography-level(1rem, 1.5rem, 400)
  ),
  density: 0
));

@include mat.all-component-themes($light-theme);

Copiez ce bloc tel quel sur un Material actuel et ça ne compilera pas. Angular Material 18 a renommé l'API M2 avec un préfixe m2- : mat.define-palette est devenu mat.m2-define-palette, mat.define-light-theme est devenu mat.m2-define-light-theme, mat.define-typography-config est devenu mat.m2-define-typography-config, et les palettes intégrées ont pris le préfixe elles aussi, donc mat.$indigo-palette est maintenant mat.$m2-indigo-palette. Mêmes arguments, nouveaux noms. Si vous démarrez un thème aujourd'hui, sautez M2 complètement et utilisez l'API M3 define-theme.

Les tokens d'abord, les composants ensuite

L'erreur que j'ai commise tôt : j'ai commencé par les composants. Ça semblait productif. Des mois plus tard, nous avions un bouton, un champ de formulaire, une modale et un tableau de données, tous construits avant que la structure de tokens soit arrêtée, et formaliser les tokens a voulu dire repasser dans chacun d'eux pour remplacer les valeurs codées en dur. Token-first n'est pas un conseil théorique, c'est du tissu cicatriciel. La règle générale : sans tokens sémantiques, un changement de couleur de marque est une chasse aux valeurs hexadécimales codées en dur dans chaque composant ; avec eux, c'est une seule modification.

La couche de tokens se trouve dans un seul fichier importé avant tout style de composant, que vous l'écriviez à la main ou que vous le génériez depuis les Figma Variables avec Style Dictionary. Les valeurs brutes correspondent à des noms sémantiques. Les noms sémantiques sont ce que les composants consomment. Quand la couleur de marque change, vous modifiez une valeur brute et la cascade sémantique fait le reste.

scss
/* styles/_tokens.scss */
:root {
  /* Raw color scale */
  --color-primary-500: #3949ab;
  --color-primary-700: #283593;
  --color-primary-900: #1a237e;
  --color-error-600: #d32f2f;
  --color-error-700: #c62828;

  /* Semantic tokens, the only layer components reference */
  --color-action-primary: var(--color-primary-700);
  --color-action-primary-hover: var(--color-primary-900);
  --color-text-primary: #1a1a2e;
  --color-text-secondary: #4a4a6a;
  --color-surface: #ffffff;
  --color-surface-variant: #f4f4f8;

  /* Spacing scale */
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  --spacing-xl: 32px;
  --spacing-2xl: 48px;

  /* Border radii */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;
  --radius-pill: 9999px;
}
Échelle de couleurs brutesbrand-blue-100 à brand-blue-900. Aucun sens sémantique, jamais utilisée… 1 Tokens sémantiques--color-primary, --color-surface, --color-on-error. Alias sensibles au … 2 Surcharges des tokens de composants Material--mat-button-filled-container-color pointe vers les tokens sémantiques 3 Surcharges au niveau du composantEn dernier recours. CSS custom properties spécifiques au composant, pou… 4

Étendre Material sans masquer son API

Une erreur courante est d'envelopper les composants Material dans des wrappers épais qui exposent une API complètement personnalisée. Le développeur consommateur doit maintenant apprendre votre wrapper et a perdu l'accès à tous les inputs Material dont il a réellement besoin. Il commence à percer l'abstraction avec ViewChild et ElementRef. Vous avez empiré les choses.

La meilleure approche est des wrappers fins qui ajoutent le comportement spécifique dont vous avez besoin et laissent Material faire le reste. Pour la personnalisation de variantes, les CSS custom property overrides scopés à un attribut host fonctionnent proprement sans toucher la surface TypeScript du composant.

typescript
// ds-button.component.ts: a thin wrapper, not a replacement
@Component({
  selector: 'ds-button',
  standalone: true,
  imports: [MatButtonModule],
  host: { '[attr.variant]': 'variant' },
  template: `
    <button mat-flat-button [disabled]="disabled">
      <ng-content />
    </button>
  `
})
export class DsButtonComponent {
  @Input() variant: 'primary' | 'secondary' | 'destructive' | 'ghost' = 'primary';
  @Input() disabled = false;
}

// ds-button.component.scss
:host([variant='destructive']) {
  --mat-button-filled-container-color: var(--color-error-600);
  --mat-button-filled-label-text-color: white;

  &:hover button {
    --mat-button-filled-container-color: var(--color-error-700);
  }
}

Champs de formulaire avec gestion d'erreurs cohérente

Dans un produit de carte de crédit, les messages d'erreur de validation ne sont pas un détail UX secondaire. Une fois le libellé revu, il ne doit pas dériver d'un écran à l'autre. Un wrapper de champ de formulaire dans le design system centralise cela : un seul endroit où toute la logique de messages d'erreur vit, avec des clés i18n, pour que personne dans l'équipe ne puisse accidentellement livrer un texte hors script.

typescript
@Component({
  selector: 'ds-form-field',
  standalone: true,
  imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, TranslateModule],
  template: `
    <mat-form-field appearance="outline" [subscriptSizing]="'dynamic'">
      <mat-label>{{ label | translate }}</mat-label>
      <!-- The wrapper renders the input itself: mat-form-field cannot find
           a matInput that is projected into it through ng-content -->
      <input matInput [formControl]="control" />
      @if (control.hasError('required') && control.touched) {
        <mat-error>{{ 'validation.required' | translate }}</mat-error>
      }
      @if (control.hasError('minlength') && control.touched) {
        <mat-error>
          {{ 'validation.minLength' | translate:{ min: control.errors?.['minlength']?.requiredLength } }}
        </mat-error>
      }
    </mat-form-field>
  `
})
export class DsFormFieldComponent {
  @Input({ required: true }) label!: string;
  @Input({ required: true }) control!: FormControl;
}

Ce qui compte vraiment à l'échelle

  • Versionnez la bibliothèque comme un package npm séparé dès le premier jour. Même si elle commence dans un monorepo, le versionnage crée de la responsabilité pour les breaking changes
  • Documentez chaque variante de composant avant d'implémenter. Ce sont de la documentation vivante et des critères d'acceptation simultanément
  • WCAG 2.1 AA n'est pas négociable dans les applications financières. Les attributs ARIA, la navigation clavier et les ratios de contraste sont intégrés aux composants, pas laissés aux équipes produit
  • Les noms de tokens sémantiques survivent aux rebrands. --color-action-primary survit à un rafraîchissement de marque. --color-indigo-700 non
  • Chaque nouveau composant ajouté à la bibliothèque passait par une revue design et une revue de code par un second développeur, même dans une équipe de trois. Sans processus, le DS devient un fourre-tout
  • Quand vous renommez un composant ou changez une API, fournissez un schematic pour que les équipes consommatrices ne restent pas indéfiniment sur les anciennes versions

Un design system n'est pas une bibliothèque de composants. C'est un langage partagé entre le design et l'ingénierie. Si les designers n'y contribuent pas et les développeurs ne le consultent pas en premier, il a déjà échoué.

Angular Material gère l'accessibilité, l'infrastructure de thème et le comportement des composants à un niveau de qualité que je ne vais pas reproduire de zéro. Construire par-dessus est le bon choix. Le vrai investissement est dans le système de tokens, le design d'API des composants, la documentation et la gouvernance qui le maintient vivant et fiable. Faites bien ces aspects et l'implémentation technique est la partie facile.

Références

AngularAngular MaterialDesign SystemUX DesignTypeScript