~8 min de lecture
withViewTransitions() : 6 pièges qui rendent tes transitions Angular invisibles
Tu as branché withViewTransitions() sur ton provideRouter. Tu navigues d'une liste vers un détail. Tu vois un flash blanc de 50ms. Tu te dis "ok, c'est pas très joli, mais au moins c'est activé". Tu passes à autre chose.
Sauf que ce n'est pas activé. Le flash que tu vois, c'est le fallback quand l'animation est cassée. Angular a bien démarré la transition, mais tes composants n'ont aucun view-transition-name, donc le navigateur n'a rien à animer entre les deux états. Résultat: un cross-fade brut sur toute la viewport, invisible parce que trop rapide, et zéro sensation de continuité pour l'utilisateur.
withViewTransitions() existe depuis Angular 17, la CSS View Transitions API est spec depuis 2023 (Chrome 111+), et personne ne l'utilise correctement. C'est dommage: bien branché, ça remplace 300 lignes d'animations Angular verbeuses par 5 lignes de CSS déclaratif, avec un fallback natif propre sur Firefox et Safari.
Voici les 6 pièges qu'il faut fermer pour que ça marche. Angular 17+ requis: withViewTransitions, skipInitialTransition et le hook onViewTransitionCreated sont tous disponibles depuis cette version.
TL;DR
- Sans
view-transition-namesur au moins un élément partagé, tu obtiens un cross-fade root invisible. - Un même
view-transition-namesur deux éléments simultanés dans la même vue casse la transition en silence. - Le hook
onViewTransitionCreatedest ta seule porte de sortie pour skip ou personnaliser une transition selon la route. - En SSR, la transition s'exécute pendant l'hydratation et fait clignoter les images: skipper la première navigation.
prefers-reduced-motionn'est pas géré par Angular. Tu dois le faire en CSS.- Le shell d'application (header, footer, sidebar) doit être isolé de la transition sinon il flashe à chaque nav.
Le problème: la transition invisible
Tu as ce setup, tu penses que tout va bien.
import { provideRouter, withViewTransitions } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withViewTransitions()),
],
};
Tu navigues de /articles vers /articles/42. Le nouveau composant s'affiche. Aucune animation visible. Tu ouvres les devtools, panneau Animations, tu ralentis à 10%. Là tu vois enfin quelque chose: un fondu enchainé de 250ms entre les deux snapshots complets de la page. Le shell (header, footer) est inclus dans le fondu, donc il clignote lui aussi. C'est pire que rien.
Le problème est simple: View Transitions API travaille sur des paires d'éléments qui partagent un même view-transition-name. Sans nom explicite, un seul groupe existe: ::view-transition-old(root) vs ::view-transition-new(root), qui contiennent toute la page. Le navigateur ne peut faire qu'un cross-fade global, donc tu perds toute continuité spatiale.
Piège 1: pas de view-transition-name = pas de transition utile
C'est le contrat fondamental. Pour animer un élément d'une vue vers l'autre, il faut le nommer, des deux côtés.
Prends une liste d'articles avec des thumbnails. Sur la liste:
@for (article of articles(); track article.id) {
<a [routerLink]="['/articles', article.id]" class="card">
<img [src]="article.thumbnail" [style.viewTransitionName]="'article-' + article.id" />
<h3>{{ article.title }}</h3>
</a>
}
Sur la page détail:
<img [src]="article().cover" [style.viewTransitionName]="'article-' + article().id" />
<h1>{{ article().title }}</h1>
Angular applique la transition, le navigateur voit qu'un élément avec view-transition-name: article-42 existe des deux côtés, il génère automatiquement un morph position + taille + opacité entre les deux. La thumbnail carrée en bas de liste devient le cover fullwidth en haut du détail. Zéro ligne de JS d'animation. Zéro dépendance tierce.
Deux points de vigilance:
- Le nom doit être unique dans la vue.
article-42est OK parce qu'un seul élément le porte à la fois. Si tu avais misarticlesans l'id, deux éléments simultanés se battraient pour le même nom et la transition serait annulée (voir piège 2). [style.viewTransitionName](camelCase) et[style.view-transition-name](dash-case) fonctionnent tous les deux dans un binding Angular, choisis celui qui te semble le plus lisible.
Piège 2: deux éléments avec le même nom = transition cassée
Le navigateur exige qu'un view-transition-name soit unique à un moment donné. Si deux éléments partagent le même nom dans la vue courante, ou dans la vue cible, la transition est abandonnée entièrement et tu retombes sur un rendu instantané sans warning console.
C'est le piège classique quand tu affiches une liste plus un aperçu détaillé:
<!-- BAD: 'hero' apparait plusieurs fois -->
<div class="preview" style="view-transition-name: hero"></div>
@for (item of items(); track item.id) {
<div class="thumb" style="view-transition-name: hero"></div>
}
La fix, c'est de réserver view-transition-name uniquement à l'élément actif ou sélectionné:
<div class="preview" [style.viewTransitionName]="'hero'"></div>
@for (item of items(); track item.id) {
<div
class="thumb"
[style.viewTransitionName]="item.id === selectedId() ? 'hero' : null"
></div>
}
Passer null retire la propriété (Angular ne rend rien pour un style.foo valant null ou undefined), donc les autres thumbs restent des éléments non nommés et ne perturbent pas la transition.
Piège 3: skipper certaines routes avec onViewTransitionCreated
Toutes les navigations ne méritent pas de transition. Les paginations, les tri de tableau, les query params qui changent seul ne devraient rien animer. Pareil pour les navigations entre onglets d'une même page: si tu re-utilises le composant, il n'y a rien à transitionner.
Depuis Angular 17, withViewTransitions accepte un second argument avec un callback onViewTransitionCreated. from et to sont des ActivatedRouteSnapshot, pas des URL: pour détecter "même route, query params différents", compare leur routeConfig.
provideRouter(
routes,
withViewTransitions({
skipInitialTransition: true,
onViewTransitionCreated: ({ transition, from, to }) => {
if (from.routeConfig === to.routeConfig) {
transition.skipTransition();
}
},
}),
);
transition.skipTransition() annule l'animation sans casser la navigation. Le rendu est instantané et tu évites le flash inutile. skipInitialTransition: true fait la même chose pour la toute première navigation (voir piège 4).
Autre usage pratique: attendre une donnée avant de démarrer l'animation. La transition démarre par défaut dès que le nouveau composant est instancié, avant même le premier binding. Sur un détail lourd qui affiche un skeleton pendant 100ms, tu vois donc: liste + skeleton + détail complet, trois états au lieu de deux. Attendre le premier signal des données lisse tout:
onViewTransitionCreated: async ({ transition, to }) => {
await firstValueFrom(this.articleService.loaded$.pipe(filter(Boolean)));
await transition.ready;
},
Piège 4: SSR + hydratation = première transition à jeter
Angular SSR sert un HTML complet. Puis le bundle browser démarre, hydrate, et déclenche la première navigation client. Cette navigation "initiale" match exactement la route SSR, mais Angular la traite comme une vraie navigation et lance une transition dessus. Résultat visible: toute la page fait un cross-fade root de 250ms au chargement, images comprises, ce qui te ruine ton LCP visible.
Le fix est en une ligne:
withViewTransitions({ skipInitialTransition: true });
Ça n'annule pas la navigation, juste la transition. À noter: skipInitialTransition couvre uniquement la première navigation. Les navigations client suivantes gardent leurs transitions normales.
Autre subtilité SSR: document.startViewTransition n'existe pas côté serveur. Angular gère déjà ce cas dans withViewTransitions et ne l'appelle que sur browser, donc ton SSR ne crashe pas. Mais si tu écris ton propre code d'animation basé sur cette API (par exemple un bouton qui déclenche une transition sans changer de route), tu dois guarder toi-même:
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
toggleTheme() {
const swap = () => this.theme.update((t) => (t === 'light' ? 'dark' : 'light'));
if (this.isBrowser && document.startViewTransition) {
document.startViewTransition(swap);
} else {
swap();
}
}
Piège 5: prefers-reduced-motion non géré côté Angular
L'utilisateur a prefers-reduced-motion: reduce dans son OS ou son navigateur. Il ne veut pas d'animations. Angular ne skippe pas les View Transitions pour lui. Résultat: violation d'accessibilité et cinétose potentielle.
Il y a deux niveaux de fix. Le plus solide, c'est CSS: tu neutralises tous les pseudos View Transitions en une règle globale.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Ça garde le mécanisme (le DOM change en une frame) mais tue toutes les animations. Zéro mouvement.
Le complément, c'est côté TS, pour éviter même le coût de démarrer une transition qui sera annulée. Attention: matchMedia n'existe pas côté serveur, donc l'appel doit rester dans le callback (exécuté uniquement côté browser), jamais au niveau du provider:
provideRouter(
routes,
withViewTransitions({
onViewTransitionCreated: ({ transition }) => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
transition.skipTransition();
}
},
}),
);
L'un ou l'autre suffit. Les deux ensemble c'est ceinture + bretelles, et c'est ce que je recommande.
Piège 6: shell d'app qui clignote à chaque nav
Ton App a un header, un footer, une sidebar. À chaque changement de route, tu vois ces trois zones flasher pendant 250ms. Cause: elles sont capturées dans ::view-transition-old(root) et animées avec le reste.
La fix, c'est de les sortir de la transition root en leur donnant un nom propre:
.app-header { view-transition-name: app-header; }
.app-footer { view-transition-name: app-footer; }
.app-sidebar { view-transition-name: app-sidebar; }
Ces éléments sont maintenant traités comme des éléments partagés stables (ils existent avant et après avec le même nom, donc pas de morph, juste une continuité).
Si tu veux qu'ils soient carrément immobiles et que seul le contenu route s'anime, tu neutralises leur animation:
::view-transition-group(app-header),
::view-transition-group(app-footer),
::view-transition-group(app-sidebar) {
animation: none;
}
Ce pattern est le plus propre. Ton contenu principal reste dans root et se cross-fade, ton shell est isolé et ne bouge pas.
Before / After: un composant liste-détail complet
Voici le squelette d'une page articles avec transition thumbnail vers cover:
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ArticleStore } from './article-store';
@Component({
selector: 'app-article-list',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
template: `
@for (article of articles(); track article.id) {
<a [routerLink]="['/articles', article.id]" class="card">
<img
[src]="article.thumbnail"
[alt]="article.title"
[style.viewTransitionName]="'article-cover-' + article.id"
/>
<h3 [style.viewTransitionName]="'article-title-' + article.id">
{{ article.title }}
</h3>
</a>
}
`,
})
export class ArticleList {
private readonly store = inject(ArticleStore);
protected readonly articles = this.store.articles;
}
Sur le détail:
@Component({
selector: 'app-article-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@let a = article();
@if (a) {
<img
[src]="a.cover"
[alt]="a.title"
[style.viewTransitionName]="'article-cover-' + a.id"
/>
<h1 [style.viewTransitionName]="'article-title-' + a.id">{{ a.title }}</h1>
<p>{{ a.body }}</p>
}
`,
})
export default class ArticleDetail {
private readonly store = inject(ArticleStore);
protected readonly article = this.store.selectedArticle;
}
Le CSS pour polir:
::view-transition-group(*) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Et l'appConfig:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withViewTransitions({
skipInitialTransition: true,
onViewTransitionCreated: ({ transition, from, to }) => {
const samePage = from.routeConfig === to.routeConfig;
if (samePage) transition.skipTransition();
},
}),
),
],
};
Total: 15 lignes de config + 2 attributs viewTransitionName. Comparé à l'équivalent en @angular/animations (triggers, states, transitions, requires shared route state entre deux composants), tu divises le code par 20 et tu obtiens une animation qui suit la spec web native, avec un fallback identique sur les navigateurs qui ne la supportent pas encore.
Récap actionnable
Checklist avant de merger une PR qui active View Transitions:
withViewTransitions({ skipInitialTransition: true })surprovideRouter. Toujours, dès qu'il y a SSR ou hydration.- Chaque paire d'éléments à animer entre deux vues a un
view-transition-nameunique par vue, calculé depuis l'id de l'entité (pas un nom statique). - Dans une liste,
view-transition-nameest appliqué uniquement à l'item selectionné, jamais à toute la liste, sinon tu casses la transition en silence. onViewTransitionCreatedskip les navigations qui gardent la même route de base (tri, pagination, query params).- Header, footer, sidebar ont leur propre
view-transition-namestable, avecanimation: nonesur leur::view-transition-grouppour ne pas bouger. - Media query
prefers-reduced-motionneutralise toutes les animations en global. - Aucun appel direct à
document.startViewTransitionsans guardisPlatformBrowser.
Support navigateur actuel: Chrome/Edge 111+, Safari 18+, Firefox derrière un flag (layout.css.view-transitions.enabled). Sur les navigateurs sans support, Angular skip l'animation et rend le changement en une frame. Zéro régression, juste pas de fondu.