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) |
@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); }
}
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>
}
: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();
}
}
})
)
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é |
opacity | GPU | ✅ Préféré |
filter (blur, brightness) | GPU léger | ✅ OK |
top, left, width, height | Layout reflow | ❌ Éviter |
background-color | Paint | ⚠️ OK pour petits éléments |
box-shadow | Paint 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);
}
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');
}
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
transformetopacityautant 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
windowcôté serveur)
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 à
transformetopacity, jamaistop/left/width. - Accessibilité : respecter
prefers-reduced-motionpar défaut, pas en dernière minute.
@defer avancé pour ne charger les animations lourdes que lorsque l'utilisateur les voit.