Sur un nouveau projet où je suis en charge de l’UIX et du Dev, cliquer sur un contenu fait arriver sa page par la droite, et revenir à la liste la fait revenir par la gauche. On sait qu’on est allé plus loin, ou qu’on est rentré, sans avoir rien lu.
Une transition bien choisie informe : elle dit « autre chose », « plus loin » ou « retour » sans un mot. La View Transition API rend ce mouvement presque gratuit à écrire, et tout se joue sur ce que le navigateur photographie, et à quel moment. L’avantage non négociable du motion design minimaliste dans l’UX.
Deux instantanés et le bon moment
Le principe est simple. Le navigateur photographie la page, appelle un rappel qui change le DOM, photographie le résultat, puis anime d’une image à l’autre. Tant que le rappel n’a pas rendu la main, l’écran reste figé sur la première.
Au premier essai, le fondu démarrait environ 900 ms après le clic, et sur la mauvaise page. Le routeur d’Astro remplace le corps de la page dans le rappel, puis rend ses props à l’îlot React, et React rend en différé : quand le navigateur prenait sa seconde photo, la nouvelle vue n’existait pas encore. Le fondu allait de l’ancienne page vers l’ancienne page, et la nouvelle apparaissait après, d’un bloc. Vu depuis la souris, ça ressemblait simplement à un site qui rame.
La durée et la courbe n’y étaient pour rien, c’était une question d’horloge. La nouvelle vue est désormais rendue de façon synchrone, avec flushSync, dans astro:after-swap, le dernier évènement synchrone du rappel. Ça coûte 51 ms, et la seconde photo est la bonne.
Tout tient au second instantané : quand il est juste, la courbe n’a plus qu’à faire son travail.
810 ms pour photographier un canvas
La page d’accueil porte un décor WebGPU. Avec lui, une capture passait de 4 ms à 810 ms : pour photographier un canvas accéléré, le navigateur relit le GPU, et cette relecture coûte plus cher que tout le reste de la page.
Le canvas est donc masqué le temps du voyage, en visibility: hidden et pas en display: none, qui redisposerait la page juste avant de la photographier. Ce qui gèle l’écran avant une transition, ce n’est pas l’animation. C’est la capture.
Ce qui reste en place porte un nom
La barre du haut, elle, clignotait. Elle était dessinée différemment sur les deux pages, et le fondu superposait pendant 0,22 s deux barres presque identiques, sur le seul élément de l’écran qui n’avait aucune raison de bouger.
Il n’y a plus qu’une barre, commune aux deux pages, et elle porte un view-transition-name. Le navigateur la sort de la capture générale et apparie celle d’avant avec celle d’après. Sans animation, l’appariement suffit : elle reste immobile pendant que la page change dessous.
.barre-haute {
view-transition-name: barre-haute;
}
::view-transition-group(barre-haute),
::view-transition-old(barre-haute),
::view-transition-new(barre-haute) {
animation: none;
mix-blend-mode: normal;
}
Restait un saut. Une page avait un ascenseur, l’autre non : la largeur utile passait de 1426 à 1441 px, et le coin droit de la barre sautait de 15 px. scrollbar-gutter: stable réserve la gouttière partout, ascenseur ou pas.
Après ces trois correctifs, le fondu partait à 178 ms et finissait à 500 ms.
Plus loin, ou retour
Une fois que chaque contenu a eu sa propre page, le site a pris une profondeur : l’accueil à 0, la liste à 1, le contenu à 2. Le fondu de 0,22 s disait « autre chose ». Un glissement latéral de 0,36 s dit « plus loin » ou « retour », sur une courbe cubic-bezier(0.22, 1, 0.36, 1) qui part vite et se pose.
La page la plus profonde passe dessus et traverse toute la largeur. L’autre ne se décale que d’un tiers et s’assombrit. Sans cet écart, deux pages sur le même fond ont l’air d’un bandeau qui défile : on voit du texte partir de côté sans comprendre qu’une page en recouvre une autre.
:root { --cote: 1; }
:root[dir="rtl"] { --cote: -1; }
@keyframes arrive-loin {
from { translate: calc(100% * var(--cote)) 0; }
}
@keyframes part-pres {
to { translate: calc(-30% * var(--cote)) 0; filter: brightness(0.7); }
}
:root[data-sens]::view-transition-old(root),
:root[data-sens]::view-transition-new(root) {
animation-duration: 0.36s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
mix-blend-mode: normal;
}
:root[data-sens="avant"]::view-transition-old(root) { animation-name: part-pres; }
:root[data-sens="avant"]::view-transition-new(root) { animation-name: arrive-loin; }
Le retour inverse les rôles : la page qui s’en va traverse la largeur et passe au-dessus avec un z-index: 1, celle qui arrive revient de son tiers. Le mix-blend-mode: normal a sa raison : pendant un fondu, le navigateur additionne les deux images, et deux pages qui se recouvrent doivent se recouvrir, pas s’éclaircir l’une l’autre. Sur le build de production, le glissement part 34 ms après le clic et finit à 412 ms.
Reste le sens. Le routeur en fournit un, « avant » ou « arrière », mais tout lien cliqué compte comme « avant ». Y compris le « ← Retour à la liste » d’un contenu, qui aurait fait entrer la liste par la droite, comme si l’on s’enfonçait alors qu’on remonte. Le sens se déduit donc de la profondeur des deux pages : plus profonde, la page entre par la droite ; moins profonde, elle revient par la gauche. L’historique ne départage que deux pages de même profondeur.
Le routeur remplace aussi les attributs de <html> pendant l’échange : un data-sens posé avant disparaît, il faut le poser juste après. Et les pseudo-éléments ::view-transition-* descendent de la racine : une variable posée sur une classe du <body> ne leur arrive jamais. --cote vit sur :root, et c’est lui qui retourne tout le mouvement en écriture de droite à gauche.
Quand je n’anime pas
Les écrans d’outil interne gardent le fondu. Un écran qui traverse la fenêtre à chaque onglet se remarque la première fois et fatigue à la dixième. Deux adresses identiques ne glissent pas non plus : rien n’a changé de profondeur, rien ne doit suggérer un voyage.
Sur mobile, le geste natif de retour a sa propre animation. Le routeur saute alors la sienne pour ne pas en jouer deux : le pouce a déjà dit « retour ».
Qui demande moins de mouvement n’en a pas. La règle habituelle, * { animation-duration: 0.01ms } sous prefers-reduced-motion, n’atteint pas les pseudo-éléments de la transition, que * ne sélectionne pas. Il faut les viser nommément.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Rien de tout ça ne demande une bibliothèque d’animation. Le motion design simple, c’est choisir ce qui bouge, ce qui reste, et dans quel sens.



