Front-end angularforall.com

- Angular Material Theming : design system cohérent

Angular Angular Material Theming Design System Ui
Angular Material Theming : design system cohérent

Créez un design system Angular Material maintenable : définissez des palettes, tokens de design, thèmes clair/sombre et personnalisations scalables pour.

Installation et configuration initiale

Angular Material est la librairie UI officielle d'Angular. Depuis Angular 17+, elle supporte nativement le Material Design 3 (M3) avec un système de tokens CSS. La migration M2 → M3 est progressive et non destructive.

# Ajouter Angular Material (Angular 17+)
ng add @angular/material

# Le CLI demande :
# 1. Thème préconçu ou personnalisé → choisir "Custom"
# 2. Typographie globale Material → Yes
# 3. Animations → provideAnimationsAsync() (recommandé)

Avec Angular 17+, utilise le provider d'animations async dans app.config.ts :

// app.config.ts — provider animations async (non bloquant)
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideAnimationsAsync(), // charge les animations en lazy par défaut
    ]
};
Recommandation M3 : Choisis "custom theme" dès le départ — même en partant d'une palette existante. Tu garderas le contrôle total sur les tokens couleur, typographie et density.

Différences M2 vs M3

Aspect Material 2 (M2) Material 3 (M3)
API thèmedefine-light-theme()define-theme()
PalettesPrimary/Accent/WarnPrimary/Secondary/Tertiary/Error
Tokens CSSNon (SCSS uniquement)Oui (variables CSS natives)
Dark modeThème séparé complettheme-type: dark
DensityManuelle par composantScale globale (-3 à 0)
Typographie8 niveaux fixes15 niveaux M3 (display/headline/body/label)

Tokens CSS et palettes M3

M3 génère automatiquement des tokens CSS (custom properties) à partir de ta palette. Ces tokens sont utilisés par tous les composants Material — tu peux aussi les consommer directement dans ton CSS.

// styles.scss — Configuration complète M3
@use '@angular/material' as mat;

// 1. Définir la palette principale (tonal palette)
$mon-theme: mat.define-theme((
    color: (
        theme-type: light,
        primary: mat.$azure-palette,      // bleu Material
        tertiary: mat.$violet-palette,    // accent complémentaire
        use-system-variables: true        // génère les tokens CSS natifs
    ),
    typography: (
        brand-family: 'Manrope, sans-serif',
        plain-family: 'Roboto, sans-serif',
        bold-weight: 700,
        medium-weight: 500,
        regular-weight: 400
    ),
    density: (
        scale: 0  // 0 normal | -1 compact | -2 dense | -3 très dense
    )
));

// 2. Appliquer le thème
html {
    @include mat.all-component-themes($mon-theme);
    // Les tokens sont maintenant disponibles comme variables CSS
}

Après application, les tokens sont accessibles dans tout ton CSS :

/* Utiliser les tokens Material dans ton CSS custom */
.mon-bouton-custom {
    background: var(--mat-sys-primary);           /* couleur primaire */
    color: var(--mat-sys-on-primary);             /* texte sur fond primaire */
    border-radius: var(--mat-sys-shape-corner-full); /* forme arrondie */
    box-shadow: var(--mat-sys-elevation-level2);  /* ombre niveau 2 */
}

.ma-card-custom {
    background: var(--mat-sys-surface);           /* fond surface */
    color: var(--mat-sys-on-surface);             /* texte sur surface */
    border: 1px solid var(--mat-sys-outline);     /* contour */
}

Palettes M3 prédéfinies

Angular Material 3 fournit des palettes tonales conformes aux specs Google :

  • mat.$red-palette, mat.$pink-palette, mat.$rose-palette
  • mat.$orange-palette, mat.$yellow-palette
  • mat.$green-palette, mat.$teal-palette, mat.$cyan-palette
  • mat.$azure-palette, mat.$blue-palette
  • mat.$violet-palette, mat.$magenta-palette

Palette totalement personnalisée

Pour une palette HEX custom, utilise mat.define-palette() avec une map de nuances (50 → 900) :

// Palette custom depuis tes valeurs HEX de marque
$mon-brand-palette: (
    50: #e8f4fd,
    100: #c5e3fb,
    200: #9ed1f9,
    300: #77bef7,
    400: #58aff5,
    500: #39a0f3,  // couleur principale
    600: #3392e4,
    700: #2b7fd0,
    800: #246dbc,
    900: #164d99,
    contrast: (
        50: rgba(0, 0, 0, 0.87),
        900: white,
        // ... autres contrastes
    )
);

// Utiliser dans le thème
$theme: mat.define-theme((
    color: (
        theme-type: light,
        primary: mat.define-palette($mon-brand-palette, 500, 300, 700)
    )
));

Thèmes clair/sombre dynamiques

M3 rend le dark mode trivial : une seule palette, deux variantes light/dark. Le changement se fait via un attribut HTML ou prefers-color-scheme.

// styles.scss — Dual theme M3
@use '@angular/material' as mat;

$theme-clair: mat.define-theme((
    color: (theme-type: light, primary: mat.$azure-palette)
));

$theme-sombre: mat.define-theme((
    color: (theme-type: dark, primary: mat.$azure-palette)
    // Même palette ! M3 ajuste automatiquement toutes les nuances tonales
));

html {
    @include mat.all-component-themes($theme-clair);
}

// Attribut HTML → prioritaire sur media query
html[data-theme="dark"] {
    @include mat.all-component-color-themes($theme-sombre);
    // Seules les couleurs sont re-appliquées (typo/density inchangées)
}

// Media query → si pas de préférence explicite
@media (prefers-color-scheme: dark) {
    html:not([data-theme="light"]) {
        @include mat.all-component-color-themes($theme-sombre);
    }
}

ThemeService Angular avec persistence

// theme.service.ts — Toggle + persistence localStorage
import { Injectable, signal, effect } from '@angular/core';

type ThemeMode = 'light' | 'dark' | 'system';

@Injectable({ providedIn: 'root' })
export class ThemeService {
    // Lire la préférence sauvegardée
    readonly mode = signal<ThemeMode>(
        (localStorage.getItem('theme') as ThemeMode) ?? 'system'
    );

    constructor() {
        // Appliquer automatiquement à chaque changement
        effect(() => {
            const mode = this.mode();
            localStorage.setItem('theme', mode);

            if (mode === 'system') {
                document.documentElement.removeAttribute('data-theme');
            } else {
                document.documentElement.setAttribute('data-theme', mode);
            }
        });
    }

    toggle(): void {
        this.mode.update(m => m === 'light' ? 'dark' : 'light');
    }

    setMode(mode: ThemeMode): void {
        this.mode.set(mode);
    }

    isDark(): boolean {
        const mode = this.mode();
        if (mode === 'system') {
            return window.matchMedia('(prefers-color-scheme: dark)').matches;
        }
        return mode === 'dark';
    }
}

Composant toggle dans la navbar

// theme-toggle.component.ts
import { Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ThemeService } from './theme.service';

@Component({
    selector: 'app-theme-toggle',
    standalone: true,
    imports: [MatIconModule, MatButtonModule, MatTooltipModule],
    template: `
        <button
            mat-icon-button
            [matTooltip]="themeService.isDark() ? 'Mode clair' : 'Mode sombre'"
            (click)="themeService.toggle()"
            aria-label="Basculer le thème"
        >
            <mat-icon>{{ themeService.isDark() ? 'light_mode' : 'dark_mode' }}</mat-icon>
        </button>
    `
})
export class ThemeToggleComponent {
    protected readonly themeService = inject(ThemeService);
}

Theming au niveau composant

Avec M3, tu peux appliquer un thème différent à un composant spécifique sans affecter le reste de l'app. Utile pour les sections marketing ou les sidebars avec une couleur tertiaire.

// dashboard.component.scss — Thème localisé
@use '@angular/material' as mat;

// Thème accentué pour la sidebar
$sidebar-theme: mat.define-theme((
    color: (
        theme-type: dark,
        primary: mat.$violet-palette  // différent du thème global
    )
));

// Applique uniquement dans .sidebar-container
.sidebar-container {
    @include mat.all-component-colors($sidebar-theme);
    // Les composants Material dans cette zone utilisent ce thème

    background: var(--mat-sys-surface);
    color: var(--mat-sys-on-surface);
}

Pour un composant standalone, configure le theming dans son host :

// stat-card.component.scss
:host {
    // Override spécifique — bouton "success" vert
    --mat-filled-button-container-color: #2e7d32;
    --mat-filled-button-label-text-color: white;
    --mdc-filled-button-container-color: #2e7d32;
}

Composants wrapper design system

Un design system solide repose sur des composants wrapper qui encapsulent les conventions Material et exposent une API métier simplifiée. Ce pattern isole les dépendances Material et facilite les migrations futures.

// ds-button.component.ts — Bouton design system
import { Component, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';

type ButtonVariant = 'filled' | 'outlined' | 'text' | 'elevated';
type ButtonSize = 'sm' | 'md' | 'lg';

@Component({
    selector: 'ds-button',
    standalone: true,
    imports: [MatButtonModule, MatIconModule, MatProgressSpinnerModule],
    template: `
        @switch (variant()) {
            @case ('filled') {
                <button mat-flat-button [disabled]="disabled() || loading()"
                    [class]="'ds-btn ds-btn--' + size()" (click)="clicked.emit()">
                    @if (loading()) {
                        <mat-spinner diameter="16"></mat-spinner>
                    } @else if (icon()) {
                        <mat-icon>{{ icon() }}</mat-icon>
                    }
                    {{ label() }}
                </button>
            }
            @case ('outlined') {
                <button mat-stroked-button [disabled]="disabled() || loading()"
                    [class]="'ds-btn ds-btn--' + size()" (click)="clicked.emit()">
                    @if (icon()) { <mat-icon>{{ icon() }}</mat-icon> }
                    {{ label() }}
                </button>
            }
            @default {
                <button mat-button [disabled]="disabled() || loading()"
                    [class]="'ds-btn ds-btn--' + size()" (click)="clicked.emit()">
                    {{ label() }}
                </button>
            }
        }
    `,
    styles: [`
        .ds-btn--sm { font-size: 0.75rem; padding: 0 12px; }
        .ds-btn--lg { font-size: 1rem; padding: 0 28px; height: 48px; }
        mat-spinner { display: inline-block; margin-right: 4px; }
    `]
})
export class DsButtonComponent {
    label = input.required<string>();
    icon = input('');
    variant = input<ButtonVariant>('filled');
    size = input<ButtonSize>('md');
    disabled = input(false);
    loading = input(false);
    clicked = output<void>();
}

Card générique design system

// ds-card.component.ts — Card pluggable
import { Component, input, contentChild } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';

@Component({
    selector: 'ds-card',
    standalone: true,
    imports: [MatCardModule, MatDividerModule],
    template: `
        <mat-card class="ds-card" [class.ds-card--elevated]="elevated()">
            @if (title()) {
                <mat-card-header>
                    <mat-card-title>{{ title() }}</mat-card-title>
                    @if (subtitle()) {
                        <mat-card-subtitle>{{ subtitle() }}</mat-card-subtitle>
                    }
                </mat-card-header>
                <mat-divider></mat-divider>
            }
            <mat-card-content>
                <ng-content></ng-content>
            </mat-card-content>
            <ng-content select="[card-actions]"></ng-content>
        </mat-card>
    `
})
export class DsCardComponent {
    title = input('');
    subtitle = input('');
    elevated = input(false);
}
Avantage isolation : Si Material change son API interne (M2 → M3 ou M3 → M4), tu n'as qu'un seul fichier wrapper à mettre à jour — pas les 50+ écrans qui l'utilisent.

Typographie et density scale

M3 définit 15 niveaux typographiques répartis en 4 familles. Angular Material expose des classes CSS directement utilisables.

FamilleNiveauxUsage recommandé
DisplayLarge / Medium / SmallTitres héros, landing pages
HeadlineLarge / Medium / SmallTitres de section H1/H2
TitleLarge / Medium / SmallTitres de cartes, dialogs
BodyLarge / Medium / SmallCorps de texte principal
LabelLarge / Medium / SmallÉtiquettes, boutons, chips
<!-- Classes typographiques M3 -->
<h1 class="mat-display-large">Héros titre</h1>
<h2 class="mat-headline-large">Section principale</h2>
<h3 class="mat-title-large">Titre de carte</h3>
<p class="mat-body-large">Corps de texte principal</p>
<p class="mat-body-medium">Corps secondaire</p>
<span class="mat-label-medium">Étiquette bouton</span>
<caption class="mat-label-small">Légende image</caption>

Density scale pour tableaux denses

// Thème dense pour admin panels / data grids
$theme-admin: mat.define-theme((
    color: (theme-type: light, primary: mat.$azure-palette),
    density: (scale: -2)
    // -2 réduit la hauteur des composants de ~8px
    // Idéal pour les tables avec beaucoup de lignes
));

// Appliquer uniquement dans l'admin section
.admin-layout {
    @include mat.all-component-themes($theme-admin);
}

// Ou juste pour les boutons
.toolbar-compact {
    @include mat.button-theme($theme-admin);
}

Formulaires Material avancés

Material Form Field est le composant le plus puissant — il gère l'état de validation, les messages d'erreur et les hints accessibles automatiquement.

// login-form.component.ts — Formulaire réactif complet
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';

@Component({
    selector: 'app-login-form',
    standalone: true,
    imports: [
        ReactiveFormsModule, MatInputModule, MatFormFieldModule,
        MatButtonModule, MatSelectModule, MatDatepickerModule, MatNativeDateModule
    ],
    template: `
        <form [formGroup]="form" (ngSubmit)="onSubmit()">

            <!-- Email avec validation et message d'erreur accessible -->
            <mat-form-field appearance="outline" class="w-100">
                <mat-label>Adresse email</mat-label>
                <input matInput formControlName="email" type="email" placeholder="exemple@domaine.com">
                <mat-icon matSuffix>email</mat-icon>
                <mat-hint>Votre email de connexion</mat-hint>
                @if (form.get('email')?.hasError('required') && form.get('email')?.touched) {
                    <mat-error>L'email est obligatoire</mat-error>
                }
                @if (form.get('email')?.hasError('email')) {
                    <mat-error>Format d'email invalide</mat-error>
                }
            </mat-form-field>

            <!-- Sélect avec options groupées -->
            <mat-form-field appearance="outline" class="w-100">
                <mat-label>Rôle</mat-label>
                <mat-select formControlName="role">
                    <mat-option value="admin">Administrateur</mat-option>
                    <mat-option value="editor">Éditeur</mat-option>
                    <mat-option value="viewer">Lecteur</mat-option>
                </mat-select>
            </mat-form-field>

            <button mat-flat-button type="submit" [disabled]="form.invalid || loading">
                @if (loading) { <mat-spinner diameter="16"></mat-spinner> }
                Connexion
            </button>
        </form>
    `
})
export class LoginFormComponent {
    private fb = inject(FormBuilder);
    loading = false;

    form = this.fb.group({
        email: ['', [Validators.required, Validators.email]],
        role: ['viewer', Validators.required],
    });

    onSubmit() {
        if (this.form.valid) {
            console.log(this.form.value);
        }
    }
}
Accessibilité formulaires : mat-error et mat-hint sont automatiquement associés au champ via aria-describedby. Les lecteurs d'écran annoncent les erreurs dès l'apparition — sans code supplémentaire.

Accessibilité et contrastes WCAG

Angular Material respecte WCAG 2.1 AA par défaut. Mais dès que tu personnalises les couleurs, la responsabilité du contraste t'incombe.

  • Contraste texte/fond : minimum 4.5:1 (texte normal), 3:1 (texte large ≥18pt). Outil : WebAIM Contrast Checker.
  • Ne jamais supprimer le focus ring Material — remplacer outline: 0 par un focus ring custom si nécessaire.
  • Icônes sans texte : toujours ajouter aria-label ou aria-hidden="true" si décoratif.
  • Dialogs : Angular Material gère automatiquement le focus trap et le retour au focus au fermeture.
  • Tester avec VoiceOver (macOS/iOS) et NVDA (Windows) sur les flux critiques.
  • Mode contraste élevé Windows : Material 3 supporte forced-colors nativement.
// Valider les contrastes dans les tests E2E
// axe-core + Cypress pour audit automatique WCAG
import { checkA11y } from 'axe-cypress';

it('Login form - no accessibility violations', () => {
    cy.visit('/login');
    checkA11y(undefined, {
        rules: {
            'color-contrast': { enabled: true },
            'label': { enabled: true },
        }
    });
});

// En Angular avec jest-axe
import { render } from '@testing-library/angular';
import { axe } from 'jest-axe';

it('should have no accessibility violations', async () => {
    const { container } = await render(LoginFormComponent);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
});

Checklist design system complète

  • Thème custom avec mat.define-theme() et use-system-variables: true
  • Dark mode via data-theme + prefers-color-scheme fallback
  • Composants wrapper dans un module DesignSystemModule partagé
  • Typographie M3 appliquée globalement via mat.typography-hierarchy()
  • Density scale -2 pour les vues admin denses
  • Tokens CSS documentés dans un Storybook
  • Tests axe-core sur tous les composants du DS

Partager