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.

Du clic au fondu, au premier essai puis après les trois correctifs Deux frises horizontales partent du même point, marqué clic. Sur la première, intitulée premier essai, un trait pointillé court du clic jusqu'à un repère vertical placé à 900 ms, légendé le fondu part, sur la mauvaise page. Sur la seconde, intitulée après les trois correctifs, le pointillé s'arrête à 178 ms, où commence une bande colorée marquée le fondu, qui se termine à 500 ms, bien avant l'endroit du repère de la première frise. PREMIER ESSAI clic 900 ms le fondu part, sur la mauvaise page APRÈS LES TROIS CORRECTIFS le fondu clic 178 ms 500 ms
Le même clic : le fondu entier tient désormais avant l'instant où l'ancien démarrait.

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.

Le glissement vers une page plus profonde, saisi à mi-course Une fenêtre au contour pointillé, coiffée d'une barre pleine légendée la barre nommée reste en place. Deux pages de même taille la traversent. Dessous, la page marquée liste · 1 est décalée vers la gauche, déborde de la fenêtre de ce côté, et son aplat est plus sombre. Dessus, bordée de la couleur d'accent, la page marquée contenu · 2 arrive par la droite et couvre déjà plus de la moitié de la fenêtre. Sous le dessin, une longue flèche vers la gauche, légendée toute la largeur, va du bord droit de la fenêtre à son bord gauche. Plus bas, une flèche courte vers la gauche, légendée un tiers, assombrie, part du bord gauche de la fenêtre. PLUS LOIN : DE LA LISTE AU CONTENU la barre nommée reste en place liste · 1 contenu · 2 toute la largeur un tiers, assombrie
Deux courses inégales, et c'est leur écart qui dit quelle page arrive.
: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.