~7 min de lecture
Tu ouvres source-map-explorer sur ton bundle prod. Tu vois @angular/animations : environ 55 KB minifiés (~15 KB gzippés sur le fil). Tu grep dans le repo. Deux fichiers l'utilisent : un modal qui fade in, une liste de notifications qui slide out. C'est tout. Tu payes ces 55 KB de JS parsé pour deux animations qu'un fichier CSS de 15 lignes ferait aussi bien.
Depuis Angular 20.2, il existe un remplaçant natif intégré au framework, sans dépendance runtime supplémentaire : les bindings animate.enter et animate.leave. La bonne nouvelle, c'est que dans la majorité des projets, tu peux supprimer @angular/animations de ton package.json.
Cet article te montre le problème, la nouvelle API, la migration d'un composant réel, et les cas où @angular/animations reste justifié.
Le problème : la moitié des projets payent le prix fort pour du CSS déguisé
Le package @angular/animations a été conçu à une époque où :
- IE11 était encore une cible (les Web Animations API n'étaient pas partout).
- CSS n'avait ni
@starting-style, ni les view transitions. - Il fallait un système de triggers pour orchestrer des animations complexes entre states.
En 2026, sur un projet moderne, la majorité des animations qu'on écrit sont :
- Un fade-in au montage d'un dropdown.
- Un slide-in pour une side sheet.
- Un scale sur un modal qui apparait.
- Un fade-out avant qu'une notification quitte le DOM.
Toutes triviales en CSS pur, sauf la dernière. Le seul cas où @angular/animations restait vraiment justifié en 2024, c'était la sortie du DOM. Quand tu fais @if (show()) { } et que show() passe à false, Angular retire l'élément instantanément. Impossible de jouer une transition d'exit en CSS parce que l'élément n'existe plus.
@angular/animations résolvait ça avec ses triggers :leave. Sauf que pour l'utiliser tu importais :
// app.config.ts
import { provideAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = {
providers: [provideAnimations()],
};
...et tu embarquais ~55 KB minifiés (~15 KB gzippés) dans ton bundle initial, plus le module platform-browser/animations. Pour un fade-out de 200 ms.
La solution : animate.enter et animate.leave (Angular 20.2+)
Depuis Angular 20.2, deux nouveaux bindings gèrent le problème de manière native, côté template, sans package additionnel.
Version déclarative, tu passes un nom de classe (ou plusieurs, séparés par un espace) :
<!-- Ajoute la classe 'fade-in' quand l'element entre dans le DOM. -->
<!-- Angular la retire quand l'animation CSS est terminee. -->
@if (isOpen()) {
<div class="modal" animate.enter="fade-in">Contenu du modal</div>
}
Et symétriquement pour la sortie :
<!-- Ajoute 'fade-out' au moment ou l'element allait etre retire. -->
<!-- Angular attend la fin de la transition/animation, PUIS supprime. -->
@if (isOpen()) {
<div class="modal" animate.enter="fade-in" animate.leave="fade-out">
Contenu du modal
</div>
}
Les styles associés sont du CSS standard :
.modal {
opacity: 1;
transform: scale(1);
}
.modal.fade-in {
animation: fadeIn 200ms ease-out forwards;
}
.modal.fade-out {
animation: fadeOut 150ms ease-in forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes fadeOut {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.95); }
}
Deux choses importantes :
- Angular détecte la fin via
animationend(outransitionendsi tu utilisestransition:). - Si aucun event de complétion n'arrive dans un délai raisonnable, Angular applique un fallback et retire l'élément quand même. Pas de noeud fantôme permanent en cas d'erreur CSS.
Zéro import runtime supplémentaire. Zéro decorator. Zéro provideAnimations().
Version impérative : quand tu as besoin d'un handler
Le binding string couvre 95% des cas. Mais parfois tu veux déclencher une logique en JS (poser un timer, jouer un son, notifier une métrique). Angular expose une version event :
import {
ChangeDetectionStrategy,
Component,
signal,
type AnimationCallbackEvent,
} from '@angular/core';
@Component({
selector: 'app-toast',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (visible()) {
<div
class="toast"
(animate.enter)="onEnter($event)"
(animate.leave)="onLeave($event)"
>
{{ message() }}
</div>
}
`,
})
export class Toast {
readonly visible = signal(true);
readonly message = signal('Sauvegarde effectuee');
onEnter(event: AnimationCallbackEvent): void {
event.target.classList.add('toast-enter');
// Analytics, telemetrie, whatever you need.
event.target.addEventListener(
'animationend',
() => event.animationComplete(),
{ once: true },
);
}
onLeave(event: AnimationCallbackEvent): void {
event.target.classList.add('toast-leave');
event.target.addEventListener(
'animationend',
() => event.animationComplete(),
{ once: true },
);
}
}
Piège subtil : dès que tu passes en binding event (animate.enter) / (animate.leave), Angular ne branche plus le listener animationend automatique. C'est toi qui deviens responsable d'appeler event.animationComplete(), sinon Angular attend le timeout par défaut (voir Piège 3) avant de retirer l'élément. Le pattern ci-dessus branche animationend à la main. Pour une animation pilotée par la Web Animations API (element.animate(...)), tu peux aussi appeler animationComplete() dans le .finished.then(...) du player retourné.
Migration réelle : un modal avant/après
Voici un composant modal qui utilisait @angular/animations. Compte les lignes.
Avant (@angular/animations) :
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import {
animate,
state,
style,
transition,
trigger,
} from '@angular/animations';
@Component({
selector: 'app-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('modalFade', [
state('void', style({ opacity: 0, transform: 'scale(0.95)' })),
state('*', style({ opacity: 1, transform: 'scale(1)' })),
transition('void => *', animate('200ms ease-out')),
transition('* => void', animate('150ms ease-in')),
]),
],
template: `
@if (isOpen()) {
<div class="backdrop" (click)="close.emit()">
<div class="modal" @modalFade (click)="$event.stopPropagation()">
<ng-content />
</div>
</div>
}
`,
})
export class Modal {
readonly isOpen = input.required<boolean>();
readonly close = output<void>();
}
Plus, dans app.config.ts :
providers: [provideAnimations()],
Après (animate.enter / animate.leave) :
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@Component({
selector: 'app-modal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (isOpen()) {
<div class="backdrop" (click)="close.emit()">
<div
class="modal"
animate.enter="modal-enter"
animate.leave="modal-leave"
(click)="$event.stopPropagation()"
>
<ng-content />
</div>
</div>
}
`,
styles: `
.modal { opacity: 1; transform: scale(1); }
.modal.modal-enter { animation: modalIn 200ms ease-out forwards; }
.modal.modal-leave { animation: modalOut 150ms ease-in forwards; }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes modalOut {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.95); }
}
`,
})
export class Modal {
readonly isOpen = input.required<boolean>();
readonly close = output<void>();
}
Retire provideAnimations() de ton app.config.ts. Retire @angular/animations de ton package.json. Retire tous les imports depuis @angular/animations et @angular/platform-browser/animations.
Bundle avant : main.js + platform-browser-animations.js + animations.js. Compte environ 55-60 KB minifiés (~15-20 KB gzippés) cumulés selon la version d'Angular. Vérifie sur ton propre projet avec source-map-explorer dist/**/*.js ou bundlephobia.
Bundle après : main.js seul. Le CSS ajouté est négligeable (moins de 1 KB gzippé).
Sur une app qui charge ~180 KB gzippés au boot, ~15 KB gzippés en moins c'est ~8% du transfert initial, et bien plus en JS parsé/exécuté (les 55 KB minifiés). Sur mobile mid-range, chaque kilobyte JS parsé coûte plus cher qu'un kilobyte CSS ; Lighthouse te le rendra sur le TTI.
Les 4 pièges à connaitre
1. animate.leave ne joue que sur les removals pilotés par Angular
Le binding intercepte les removals déclenchés par @if, @for, @switch, *ngIf, *ngFor. Si tu retires un noeud à la main avec element.remove() ou renderer.removeChild(), Angular ne le voit pas et ta transition ne joue pas. C'est logique : Angular ne peut retarder que ce qu'il contrôle.
Corollaire : pour un composant enveloppé dans un @if, l'exit joue sur ce composant, pas sur son parent. Structure ton template en conséquence.
2. SSR : pas d'animation post-hydration par défaut
Côté serveur, il n'y a pas de DOM interactif : les bindings sont rendus dans leur état final (l'élément apparait déjà « entré »). Après hydration côté client, tu ne veux généralement pas rejouer une animation d'entrée sur du contenu déjà visible, sinon flash visuel. Si tu veux une animation post-hydration explicite (par exemple pour un modal ouvert par défaut), branche-la sur afterNextRender côté client au lieu de compter sur animate.enter. Combine avec ton signal prefersReducedMotion pour respecter la préférence utilisateur (voir prefers-reduced-motion au-delà du reset).
3. Le fallback timing : 4 secondes par défaut
Si ton CSS n'émet ni animationend ni transitionend (typo, propriété non animée, display: none qui court-circuite), Angular attend un délai puis retire l'élément quand même. Le défaut est 4 secondes, configurable via le token MAX_ANIMATION_TIMEOUT fourni à bootstrapApplication. Ça t'évite le noeud fantôme permanent en cas d'erreur CSS.
Deux corollaires :
- Avec la version déclarative
animate.leave="fade-out", tu es couvert : Angular brancheanimationendtout seul. Le timeout n'intervient qu'en cas d'erreur. - Avec la version event
(animate.leave), Angular ne branche plus rien : tu dois appelerevent.animationComplete()toi-même. Si tu oublies, ton élément reste dans le DOM ~4 secondes après la sortie visuelle. Bug silencieux, cache mémoire qui gonfle, logs analytics qui firent en double.
4. Ne mixe pas animate.enter et @angular/animations sur le même élément
Techniquement possible, mais les deux systèmes se marchent dessus : deux listeners animationend, ordre de nettoyage indéfini, effets visuels imprévisibles. Migre un composant entier à la fois. Si tu ne peux pas encore virer @angular/animations du projet (une lib tierce en dépend), c'est OK d'avoir les deux dans le bundle temporairement, mais pas sur le même noeud.
Quand @angular/animations reste justifié
Trois cas où tu gardes le package (ou une autre API) :
Animations pilotées par une machine à états complexe : plusieurs states asymétriques (
idle => hover => active => disabled), transitions cross-state calculées,stylesen JS réactif. Les triggers avecquery()multi-éléments etstagger()restent plus concis que le CSS équivalent.Dépendance transitive incontournable : une lib tierce (composant Material ancien, wrapper d'un design system interne) qui importe encore
@angular/animations. Dans ce cas, tu ne peux pas retirer le package tant que la dépendance n'est pas migrée, mais tu peux au moins arrêter d'en ajouter dans ton propre code.Animation de transition de route : pour un cross-fade ou un shared element entre deux routes,
animate.enter/animate.leavene suffit pas (chaque page est un arbre séparé). Le bon outil estwithViewTransitions()du router (voir view transitions et pièges router), qui s'appuie sur la Web View Transitions API et donne un résultat plus concis et plus fluide.
En dehors de ces cas, animate.enter / animate.leave couvre tout ce que 95% des applications font au quotidien.
Récap actionnable
- Angular 20.2+ : préférer
animate.enteretanimate.leavesur@angular/animationspour toute animation d'entrée/sortie DOM. - Bundle : virer
@angular/animationsetprovideAnimations()fait économiser environ 55-60 KB minifiés (~15-20 KB gzippés) sur le bundle initial. - Binding string :
animate.enter="my-class"couvre 95% des cas. Déclaratif, lisible comme du HTML, testable comme n'importe quel style CSS. - Binding event :
(animate.enter)="fn($event)"pour brancher analytics, timers, ou animations JS-driven avecAnimationCallbackEvent. Tu deviens responsable d'appelerevent.animationComplete(). animate.leaveretarde le removal : Angular attend la fin de l'animation avant de retirer le noeud du DOM.- SSR : pas d'animation post-hydration par défaut. Pour un cas explicite, branche sur
afterNextRendercôté client. - Fallback timing : 4 secondes par défaut, configurable via le token
MAX_ANIMATION_TIMEOUT. - Migration progressive : par composant, ne mixe jamais les deux systèmes sur le même noeud.
- Cas restants : machines à états asymétriques complexes, dépendances tierces, transitions de route (préfère
withViewTransitions()).
Le vrai gain, ce n'est pas seulement les KB économisés, même si ton Lighthouse te dira merci. C'est que tes animations reviennent dans le fichier CSS, là où elles ont toujours dû être. Le template redevient descriptif au lieu d'orchestrateur, et ta liste de dépendances raccourcit d'un cran.