next.jsanimationscss et mises en page

View Transitions dans Next.js : animations de pages sans bibliothèque

View Transitions dans Next.js : animations de pages sans bibliothèque

Dans le dernier article, j'ai montré que l'INP mesure l'écart entre un clic et le frame qui le reflète, et qu'une seule longue tâche sur le thread principal peut ruiner cet écart, peu importe le coût de l'animation elle-même. Les transitions de pages sont la version extrême de ce problème : elles demandent au thread principal de faire son travail le plus coûteux – rendre une page entièrement nouvelle – et de faire tourner une animation fluide à 60fps, simultanément.

Ce que les bibliothèques faisaient vraiment : FLIP

En 2015, Paul Lewis a décrit une technique qu'il a appelée FLIP – First, Last, Invert, Play. On ne peut pas animer à moindre coût un élément d'une position de layout à une autre, car top et left forcent un recalcul de layout à chaque frame. Alors on triche. On enregistre la position First, on laisse le DOM sauter instantanément à son état Last, on Inverse ce saut avec un transform qui fait que l'élément semble ne pas avoir bougé, et enfin on Play le transform jusqu'à zéro. L'utilisateur voit un glissement fluide. Le navigateur n'a jamais animé qu'un transform.

La View Transitions API reprend cette astuce et la donne au navigateur. Au lieu de garder deux arbres DOM vivants et de les mesurer, le navigateur photographie l'ancien état, laisse le DOM se mettre à jour librement, photographie le nouvel état, et anime entre les deux images – sur le compositeur.

La séquence et l'arbre qu'elle construit

Le cœur de l'API est un seul appel. Encapsulez une mise à jour DOM dans document.startViewTransition() et le navigateur exécute une séquence fixe :

javascript

function navigate(update) {
if (!document.startViewTransition) {
update(); // pas de support : juste mettre à jour, pas d'animation
return;
}
document.startViewTransition(() => update());
}

Cet arbre de pseudo-éléments vaut la peine d'être connu par son nom :

css

::view-transition /* la superposition couvrant toute la page */
::view-transition-group(name) /* anime la taille et la position */
::view-transition-image-pair(name)
::view-transition-old(name) /* le snapshot "avant" */
::view-transition-new(name) /* le snapshot "après" */

La division est importante. Le groupe est ce qui se déplace et change de taille – donc animation-duration y appartient. La paire old/new est ce qui fait le fondu croisé.

Ce qui surprend tout le monde : un snapshot est une photo, pas un élément

::view-transition-old n'est pas votre ancien élément rendu plus petit. C'est une image statique de lui, capturée une fois, à un instant.

Tout découle de là. Une <video> dans une transition gèle sur une frame pendant toute la durée. Un spinner arrête de tourner. Le texte dans le snapshot ne peut pas être sélectionné. Un GIF s'arrête. Une animation CSS dans l'élément capturé se met en pause à mi-chemin.

À 200ms, rien de tout ça n'a d'importance. À 1000ms, tout devient évident et légèrement cassé. C'est la vraie raison de garder les transitions courtes.

Nommage : tout le contrat

Par défaut, on obtient un fondu de la page entière. Le comportement intéressant commence quand on donne à un élément un view-transition-name. Le même nom des deux côtés signifie que le navigateur déplace et met à l'échelle l'un dans l'autre.

css

.card-image {
view-transition-name: hero;
}
::view-transition-group(hero) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}

La règle absolue : un view-transition-name doit être unique dans le document à tout moment.

React 19.2 et Next.js 16 : un seul composant

React résout cela avec le composant <ViewTransition>, et Next.js l'utilise depuis la version 16 sans configuration.

tsx

import { ViewTransition } from "react";
import Image from "next/image";
import Link from "next/link";
export function ProjectCard({ project }) {
return (
<Link href={"/projects/" + project.slug}>
<ViewTransition name={"project-" + project.slug}>
<Image src={project.cover} alt={project.title} />
</ViewTransition>
</Link>
);
}

Un détail des docs Next.js mérite d'être répété car il échoue silencieusement : share et default="none" doivent voyager ensemble. Avec default="none" et sans prop share, la paire cesse silencieusement de se morpher.

Direction : dire à l'utilisateur où il est allé

Next.js 16.2 a ajouté transitionTypes à <Link>, ce qui permet à <ViewTransition> de mapper chaque tag sur une animation différente.

tsx

<Link href="/projects/runvalis" transitionTypes={["nav-forward"]}>
Runvalis
</Link>
<ViewTransition
enter={{ "nav-forward": "slide-in", "nav-back": "slide-out", default: "none" }}
exit={{ "nav-forward": "slide-out", "nav-back": "slide-in", default: "none" }}
default="none"
>
<main>{children}</main>
</ViewTransition>

La version sans framework du tout

css

@view-transition {
navigation: auto;
}

C'est toute la configuration. C'est l'argument le plus fort que cette API n'est pas une fonctionnalité React qui utilise CSS par hasard, mais une fonctionnalité du navigateur que React expose joliment.

Pièges et bonnes pratiques

  • Toujours le traiter comme une amélioration progressive. Là où le navigateur manque de support, rien ne casse.
  • Déboguer au ralenti. Le panneau Animations de Chrome DevTools permet de réduire la lecture à 25% ou 10%.
  • Respecter prefers-reduced-motion. Les diapositives directionnelles simulent un mouvement sur tout le viewport.
  • La superposition mange les clics par défaut. Définir ::view-transition { pointer-events: none }.
  • Les snapshots coûtent de la mémoire.
  • Ancrer l'en-tête.
  • Une view transition est invisible pour les technologies d'assistance.

Conclusion

View Transitions sont le même argument appliqué à la navigation : au lieu d'exécuter FLIP à la main, on déclare ce qui connecte deux états et on laisse le navigateur dessiner la connexion à partir de deux photos.