Front-end angularforall.com

- Animations Angular : transitions modernes

Angular Animations View-Transitions Css-Animations Performance Accessibilite Router Reduced-Motion Starting-Style Keyframes Transitions Animations-Module
Animations Angular : transitions modernes

Maîtrisez les animations Angular 2026 : @angular/animations, View Transitions API, CSS animations modernes, performance 60fps et accessibilité reduced-motion.

Choisir entre CSS, animations.module et View Transitions

Angular 17+ propose trois grandes voies pour animer une UI. Le bon choix dépend de la complexité, de la nécessité d'orchestration et de la cible navigateur.

Outil Quand utiliser Coût bundle
CSS pur (transition / @keyframes) Hover, focus, fade simple, pulse, spinner 0 KB
@angular/animations États complexes, transitions liées à un trigger, animations imbriquées ~25 KB gzip
View Transitions API Transitions de route, hero images, morphing entre vues 0 KB (natif navigateur)
Web Animations API (WAAPI) Animations programmatiques fines, contrôle play/pause 0 KB (natif)
Règle d'or 2026 : CSS d'abord. View Transitions ensuite. @angular/animations seulement si vous avez besoin d'orchestrer des états Angular avec des transitions complexes (entrée/sortie liée à *ngIf, @for animée, etc.).

Animations CSS natives modernes

Les CSS modernes (Chrome 114+, Firefox 118+, Safari 17+) permettent désormais des animations puissantes sans JavaScript : @starting-style pour l'entrée, transition-behavior: allow-discrete pour display, et animation-timeline pour les animations liées au scroll.

Fade-in d'un élément qui apparaît avec @if :

// Composant
@Component({
    template: `
        @if (visible()) {
            <div class="fade-in">Contenu animé</div>
        }
        <button (click)="toggle()">Toggle</button>
    `,
    styles: `
        .fade-in {
            opacity: 1;
            transform: translateY(0);
            transition: opacity 250ms ease, transform 250ms ease;
        }
        /* État de départ avant insertion DOM */
        @starting-style {
            .fade-in {
                opacity: 0;
                transform: translateY(8px);
            }
        }
    `
})
export class FadeComponent {
    readonly visible = signal(true);
    toggle() { this.visible.update(v => !v); }
}

@starting-style est une CSS rule qui définit l'état initial d'un élément au moment de son insertion dans le DOM. Le navigateur calcule la transition automatiquement vers l'état final.

Animation de sortie : transition-behavior: allow-discrete

.modal {
    display: none;
    opacity: 0;
    transform: scale(0.95);
    /* Permet la transition sur display (normalement non-animable) */
    transition: display 200ms allow-discrete,
                opacity 200ms ease,
                transform 200ms ease;
}
.modal.is-open {
    display: block;
    opacity: 1;
    transform: scale(1);
}
@starting-style {
    .modal.is-open {
        opacity: 0;
        transform: scale(0.95);
    }
}

Scroll-driven animation native :

/* La barre de progression se remplit en fonction du scroll de la page */
.reading-progress {
    position: fixed;
    top: 0; left: 0;
    height: 4px;
    background: var(--brand-color);
    transform-origin: left;
    animation: progress-grow linear;
    animation-timeline: scroll(root);
}
@keyframes progress-grow {
    from { transform: scaleX(0); }
    to   { transform: scaleX(1); }
}
Compatibilité : animation-timeline est en Chrome/Edge depuis 115. Firefox/Safari sont en cours d'implémentation. Prévoir un fallback (animation classique sans animation-timeline) pour ces navigateurs.

@angular/animations en pratique

Le module @angular/animations reste pertinent pour les animations qui dépendent étroitement de l'état Angular et qui nécessitent une orchestration complexe (séquences, parallélisme, requêtes sur des enfants).

Setup avec un composant standalone :

// app.config.ts
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
    providers: [provideAnimations()]
};

Animation d'état (collapsible) :

import { trigger, state, style, animate, transition } from '@angular/animations';
import { Component, signal } from '@angular/core';

@Component({
    selector: 'app-collapse',
    standalone: true,
    template: `
        <button (click)="toggle()">{{ isOpen() ? 'Fermer' : 'Ouvrir' }}</button>
        <div [@expandCollapse]="isOpen() ? 'open' : 'closed'" class="panel">
            <ng-content />
        </div>
    `,
    animations: [
        trigger('expandCollapse', [
            // État replié : hauteur 0, opacité 0
            state('closed', style({ height: '0', opacity: 0, overflow: 'hidden' })),
            // État déplié : hauteur auto, opacité 1
            state('open', style({ height: '*', opacity: 1 })),
            // Transition entre les deux états
            transition('closed <=> open', animate('250ms cubic-bezier(0.4, 0, 0.2, 1)'))
        ])
    ]
})
export class CollapseComponent {
    readonly isOpen = signal(false);
    toggle() { this.isOpen.update(v => !v); }
}

Les notations clés :

  • style({ height: '*' }) capture la hauteur calculée — pratique pour les contenus dynamiques.
  • 'closed <=> open' applique la transition dans les deux sens.
  • Les state() sont les valeurs lisibles par [@trigger] dans le template.

Animation d'entrée/sortie avec :enter / :leave :

animations: [
    trigger('fadeInOut', [
        // L'élément vient d'être ajouté au DOM
        transition(':enter', [
            style({ opacity: 0, transform: 'translateY(10px)' }),
            animate('200ms ease', style({ opacity: 1, transform: 'translateY(0)' }))
        ]),
        // L'élément va être retiré du DOM
        transition(':leave', [
            animate('150ms ease', style({ opacity: 0, transform: 'translateY(-10px)' }))
        ])
    ])
]

// Template
@if (toast()) {
    <div [@fadeInOut] class="toast">{{ toast() }}</div>
}
Pourquoi pas en CSS ? Pour :enter, on peut utiliser @starting-style en CSS. Pour :leave en revanche, le DOM est déjà retiré quand l'animation devrait jouer. @angular/animations retient l'élément le temps de l'animation de sortie — d'où sa pertinence dans ce cas.

View Transitions API et Angular Router

La View Transitions API est l'API navigateur la plus excitante de 2024-2026 pour les animations. Elle permet d'animer fluidement le passage d'un état DOM à un autre. Angular 17+ s'intègre nativement avec elle pour les transitions de route.

Activation dans le router :

// app.config.ts
import { provideRouter, withViewTransitions } from '@angular/router';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(
            routes,
            // Active automatiquement document.startViewTransition() entre les routes
            withViewTransitions()
        )
    ]
};

Définir des éléments « partagés » entre deux pages :

/* Page liste — image de la card */
.product-card-image {
    view-transition-name: product-hero;
}

/* Page détail — même image agrandie */
.product-detail-image {
    view-transition-name: product-hero;
}

/* Personnaliser l'animation par défaut */
::view-transition-old(product-hero),
::view-transition-new(product-hero) {
    animation-duration: 400ms;
    animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

Le navigateur identifie les éléments avec le même view-transition-name et les anime fluidement entre les deux états — un effet « hero animation » sans une ligne de JavaScript.

Hook personnalisé pour des animations conditionnelles :

provideRouter(
    routes,
    withViewTransitions({
        // Ne pas animer la première navigation (au chargement)
        skipInitialTransition: true,
        // Personnaliser le déclenchement
        onViewTransitionCreated: ({ transition, from, to }) => {
            // Désactiver les transitions pour certains routes
            if (to.url.toString().startsWith('/admin')) {
                transition.skipTransition();
            }
        }
    })
)
Compatibilité : View Transitions API est disponible en Chrome/Edge 111+ et Safari 18+. Firefox est en cours. Angular détecte automatiquement le support et fallback à un comportement classique si l'API n'existe pas — pas de risque de régression.

Animer une liste qui change (entrée/sortie)

Animer l'ajout/retrait d'éléments d'une liste est un classique. Avec @for Angular 17+ et @angular/animations, le pattern est élégant.

import { trigger, transition, style, animate, query, stagger } from '@angular/animations';

@Component({
    template: `
        <ul [@listAnim]="todos().length">
            @for (todo of todos(); track todo.id) {
                <li>{{ todo.label }} <button (click)="remove(todo.id)">X</button></li>
            }
        </ul>
    `,
    animations: [
        trigger('listAnim', [
            transition('* => *', [
                // Cible chaque nouvel élément ajouté
                query(':enter', [
                    style({ opacity: 0, transform: 'translateX(-20px)' }),
                    // stagger applique un délai progressif entre éléments
                    stagger(50, animate('200ms ease', style({ opacity: 1, transform: 'translateX(0)' })))
                ], { optional: true }),
                // Cible chaque élément retiré
                query(':leave', [
                    stagger(50, animate('150ms ease', style({ opacity: 0, transform: 'translateX(20px)' })))
                ], { optional: true })
            ])
        ])
    ]
})
export class TodoListComponent {
    readonly todos = signal<Todo[]>([]);

    add(label: string) {
        this.todos.update(t => [...t, { id: Date.now(), label }]);
    }
    remove(id: number) {
        this.todos.update(t => t.filter(x => x.id !== id));
    }
}

Note importante : avec le nouveau control flow @for, l'option track est obligatoire. Le tracking par identifiant unique permet aux animations de jouer correctement (sinon Angular ne sait pas quel élément est ajouté ou retiré).

Performance : 60 fps en règle

Une animation à 60 fps signifie que chaque frame doit être calculée en moins de 16,7 ms. Pour y parvenir, deux règles fondamentales :

1. Animer uniquement les propriétés « cheap »

Propriété Coût Recommandation
transform (translate, scale, rotate)GPU✅ Préféré
opacityGPU✅ Préféré
filter (blur, brightness)GPU léger✅ OK
top, left, width, heightLayout reflow❌ Éviter
background-colorPaint⚠️ OK pour petits éléments
box-shadowPaint coûteux⚠️ Préférer filter: drop-shadow

2. Indiquer au navigateur ce qui va changer

.card {
    /* Avertit le navigateur de promouvoir l'élément en couche GPU */
    will-change: transform, opacity;
    transition: transform 200ms ease;
}
.card:hover {
    transform: translateY(-4px);
}
Attention : ne pas abuser de will-change. Sur trop d'éléments, cela consomme de la mémoire GPU. Réservez-le aux éléments effectivement animés au moment où ils vont l'être (ex : ajouté en JS au mousedown, retiré au mouseup).

3. Mesurer avec les DevTools

L'onglet Performance de Chrome DevTools affiche un graph FPS et identifie les frames longues. Cherchez :

  • Des frames jaunes ou rouges (> 16 ms)
  • Des « Layout shift » non désirés
  • Des « Paint » fréquents sur de larges zones

Accessibilité et prefers-reduced-motion

Les animations peuvent provoquer nausées et migraines chez certains utilisateurs (vestibular disorders). La WCAG 2.3.3 exige de respecter la préférence système prefers-reduced-motion.

En CSS :

/* Animation par défaut */
.card {
    transition: transform 300ms ease;
}
.card:hover { transform: scale(1.05); }

/* Respect de la préférence utilisateur */
@media (prefers-reduced-motion: reduce) {
    .card {
        transition: none;
    }
    .card:hover {
        transform: none;
    }
}

Avec @angular/animations :

import { Injectable, inject, signal, effect } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class MotionService {
    // Lit la préférence système, à jour en temps réel
    readonly prefersReducedMotion = signal(
        window.matchMedia('(prefers-reduced-motion: reduce)').matches
    );

    constructor() {
        // Mettre à jour si l'utilisateur change la préférence sans recharger
        const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
        mq.addEventListener('change', e => this.prefersReducedMotion.set(e.matches));
    }
}

// Dans un composant
@Component({ /* ... */ })
export class MyComponent {
    private motion = inject(MotionService);
    // Désactive l'animation si l'utilisateur préfère
    readonly trigger = computed(() => this.motion.prefersReducedMotion() ? null : 'fadeIn');
}
Astuce SSR : côté serveur, window n'existe pas. Utilisez isPlatformBrowser(platformId) avant de lire matchMedia, et défaut à false (animations actives) côté serveur.

Cas réels : tooltip, drawer, page transitions

Cas 1 — Tooltip qui apparaît au hover (CSS pur)

.tooltip-wrapper { position: relative; }
.tooltip {
    position: absolute;
    top: -40px; left: 50%;
    transform: translateX(-50%) translateY(4px);
    opacity: 0;
    pointer-events: none;
    transition: opacity 150ms, transform 150ms;
    background: #222; color: #fff; padding: .25rem .5rem;
    border-radius: 4px; font-size: .875rem;
}
.tooltip-wrapper:hover .tooltip,
.tooltip-wrapper:focus-within .tooltip {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
}

Cas 2 — Drawer latéral (combo CSS + CDK)

.drawer {
    position: fixed; top: 0; right: 0; height: 100%;
    width: 320px;
    transform: translateX(100%);
    transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
    box-shadow: -2px 0 12px rgba(0,0,0,.15);
}
.drawer.is-open {
    transform: translateX(0);
}
.drawer-overlay {
    position: fixed; inset: 0;
    background: rgba(0,0,0,.4);
    opacity: 0;
    pointer-events: none;
    transition: opacity 250ms;
}
.drawer-overlay.is-open {
    opacity: 1;
    pointer-events: all;
}

Cas 3 — Transition de page hero (View Transitions)

Sur la liste de produits, chaque vignette possède view-transition-name: product-{id}. Sur la page détail, l'image agrandie reprend le même nom. Au changement de route, le navigateur anime automatiquement la transition entre les deux positions/tailles. Aucun JavaScript de votre côté — juste CSS et le router Angular.

  • Choisir le bon outil selon le cas (CSS / animations / view-transitions)
  • Animer uniquement transform et opacity autant que possible
  • Toujours respecter prefers-reduced-motion
  • Mesurer le FPS avec Chrome DevTools sur du vrai mobile
  • Limiter les durées à 200-400 ms pour les UI (au-delà, l'utilisateur attend)
  • Vérifier que les animations fonctionnent avec SSR (pas de window côté serveur)
Pour aller plus loin : consultez la documentation officielle angular.dev/guide/animations et l'article de blog.angulartraining.com sur les animations modernes.

Conclusion : la bonne stack en 2026

L'écosystème animation Angular s'est enrichi sans casser l'existant. CSS natif (@starting-style, transition-behavior: allow-discrete) couvre 80 % des cas avec un coût bundle nul. @angular/animations reste pertinent pour les chorégraphies complexes et les listes animées. Et la View Transitions API change la donne pour les transitions inter-pages.

À retenir pour choisir la bonne approche :

  • CSS d'abord : tout ce qui est hover, :focus, ouverture/fermeture simple → CSS pur, zéro JS.
  • @angular/animations : listes dynamiques avec :enter/:leave, séquences complexes, animations conditionnelles à un signal.
  • View Transitions : transitions de page (Router) et entre vues (modale ↔ détail), avec fallback automatique sur navigateurs anciens.
  • 60 fps obligatoire : se limiter à transform et opacity, jamais top/left/width.
  • Accessibilité : respecter prefers-reduced-motion par défaut, pas en dernière minute.
Pour aller plus loin : mesurez vos animations avec les outils de l'optimisation des bundles et complétez avec @defer avancé pour ne charger les animations lourdes que lorsque l'utilisateur les voit.

Partager