Drive React 19.3 : animations d’entrée et de sortie des transitions d’affichage depuis JavaScript
Gérer les transitions d’affichage de React 19 depuis JavaScript à l’aide de onEnter, onExit, onUpdate et onShare, ainsi que de l’API des animations web, pour des effets différés impossibles avec les classes CSS.
Sources: React 19.3 View Transition Events: Drive Enter, Exit and Share Animations from JavaScript
Les quatre événements et ce qu’ils reçoivent
Les classes View Transition gèrent la plupart des animations : une classe par déclencheur, complétée par des keyframes CSS. Elles ne fonctionnent pas lorsque l’animation dépend de valeurs connues uniquement de JavaScript — comme l’index d’une liste, une préférence utilisateur ou une constante de ressort. Dans ces cas, <ViewTransition> expose quatre propriétés d’événement qui vous fournissent les pseudo-éléments de capture de l’ navigateur, afin que vous puissiez les contrôler à l’aide de l’API Web Animations. Ce contenu s’appuie sur les déclencheurs et les types de transition abordés précédemment dans la série View Transition de React 19.3.
<ViewTransition
onEnter={(instance, types) => { /* … */ return () => { /* cleanup */ }; }}
onExit={(instance, types) => { /* … */ }}
onUpdate={(instance, types) => { /* … */ }}
onShare={(instance, types) => { /* … */ }}
>
React déclenche un événement par bordure et par transition, en fonction du déclencheur sélectionné ; onShare prend le pas sur onEnter et onExit. Chaque fonction de rappel reçoit :
instance.old— la capture « avant » de::view-transition-old, accompagnée de.animate()
instance.new — le cliché « après » ::view-transition-newinstance.group — la boîte ::view-transition-group qui se déplace et change de tailleinstance.imagePair — ::view-transition-image-pair qui contient les deux clichésinstance.name — le nom view-transition-name attribué par React (ou votre propre valeur)types — Types de transition provenant de addTransitionType, ou un tableau videLa fonction de rappel s’exécute après que la promesse ready de la transition ait été résolue : les clichés existent, et les animations par défaut du navigateur ainsi que toutes les classes CSS ont déjà été appliquées. Il faut retourner une fonction de nettoyage. Lorsqu’une autre transition débute ou que le document est masqué, le navigateur interrompt l’exécution ; c’est par cette fonction de nettoyage qu’une animation WAAPI inachevée est annulée.
Gérer un instantané via WAAPI
L’appel de .animate(keyframes, options) sur instance.new se comporte comme la méthode d’élément et génère un handle Animation. Schéma : lancer la timeline, conserver l’objet, puis le supprimer lors du nettoyage.
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateY(24px) scale(.96)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 380, easing: 'cubic-bezier(.2,.9,.25,1.15)', fill: 'both' }
);
return () => anim.cancel();
}}
CSS et JavaScript peuvent partager les tâches. L’effet de transition croisée est géré par CSS (enter="auto", exit="auto") tandis que le déplacement — avec un dépassement ajustable via une glissière — est géré par JavaScript.
import { ViewTransition, useState, startTransition } from 'react';
export default function App() {
const [show, setShow] = useState(false);
const [bounce, setBounce] = useState(1.15); return (
<>
<div className="bar">
<button onClick={() => startTransition(() => setShow(s => !s))}>
{show ? 'Dismiss' : 'Show'} notification
</button>
<label>overshoot <input type="range" min="1" max="1.6" step="0.05" value={bounce}
onChange={e => setBounce(Number(e.target.value))} /> {bounce}</label>
</div> {show && (
<ViewTransition
enter="auto"
exit="auto"
default="none"
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateY(28px) scale(.94)' }, { transform: 'none' }],
{ duration: 420, easing: `cubic-bezier(.2,.9,.25,${bounce})`, fill: 'both' }
);
return () => anim.cancel();
}}
onExit={(instance) => {
const anim = instance.old.animate(
[{ transform: 'none' }, { transform: 'translateY(28px) scale(.94)' }],
{ duration: 220, easing: 'ease-in', fill: 'both' }
);
return () => anim.cancel();
}}
>
<div className="toast">🔔 Your build finished in 42 s</div>
</ViewTransition>
)}
</>
);
}
.bar { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.toast { margin-top: 12px; padding: 12px 16px; border-radius: 10px; background: light-dark(#fef3c7, #4a3410); }
/* the fade is CSS; slow the UA default so both layers are visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 420ms; }
La valeur de la glissière est stockée dans la fermeture du composant, ce qui permet à l’événement de la lire au moment de l’animation sans avoir recours à des variables CSS ou à des noms de classe calculés.
Quelque chose que CSS ne peut pas faire : un décalage depuis une fermeture
L’index de chaque élément est déterminé progressivement. En CSS, cela signifie généralement une propriété personnalisée --i ainsi que des retards via calc(), et ces valeurs n’atteignent jamais les pseudo-éléments view-transition. À l’intérieur d’un événement, l’index est déjà disponible dans le contexte :
import { ViewTransition, useState, useDeferredValue } from 'react';
const RESULTS = ['Frontend Master — Mock interviews', 'React 19.3, explained', 'View Transitions, part 5', 'Fragment Refs', 'use(browser())'];function Result({ text, index }) {
return (
<ViewTransition
default="none"
enter="auto"
onEnter={(instance) => {
const anim = instance.new.animate(
[{ transform: 'translateX(-16px)', opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration: 300, delay: index * 70, easing: 'ease-out', fill: 'both' }
);
return () => anim.cancel();
}}
>
<li>{text}</li>
</ViewTransition>
);
}export default function App() {
const [q, setQ] = useState('');
// the input stays urgent; the list follows a deferred value, which is a Transition
const deferredQ = useDeferredValue(q);
const hits = deferredQ ? RESULTS.filter(r => r.toLowerCase().includes(deferredQ.toLowerCase())) : [];
return (
<>
<input placeholder="type 'react' or 'r'" value={q} onChange={e => setQ(e.target.value)} />
<ul className="hits">
{hits.map((h, i) => <Result key={h} text={h} index={i} />)}
</ul>
</>
);
}
input { width: 100%; box-sizing: border-box; }
.hits { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 6px; }
.hits li { padding: 8px 12px; border-radius: 8px; background: light-dark(#f1f5f9, #262a33); }
/* keep the UA fade out of the way; the JS animation owns opacity too */
::view-transition-new(*) { animation: none; }
Deux détails sont importants. En définissant ::view-transition-new(*) { animation: none }, on supprime l’effet de fondu du navigateur, ce qui laisse uniquement le JavaScript gérer l’opacité ; sinon, les effets de fondu s’empilent. De plus, fill: 'both' est essentiel pour les animations différées : sans cela, la capture reste entièrement opaque pendant le délai, avant de changer lorsque l’animation commence.
Utiliser les types de transition à l’intérieur du callback
Le deuxième argument est la liste générée par addTransitionType. Considérez-la comme une alternative programmable aux tableaux de classes CSS lorsque la durée ou la direction doit être calculée.
onUpdate={(instance, types) => {
const duration = types.includes('fast') ? 120 : 400;
const dir = types.includes('back') ? -1 : 1;
const anim = instance.new.animate(
[{ transform: `translateX(${dir * 40}px)`, opacity: 0 }, { transform: 'none', opacity: 1 }],
{ duration, easing: 'ease-out', fill: 'both' }
);
return () => anim.cancel();
}}
Lorsque la correspondance est une simple recherche, privilégiez la forme classe-objet afin que les animations restent gérées par CSS et conservent leur capacité à être inspectées. Recourez à l’événement correspondant lorsque un type doit servir de base à un calcul.
Routeurs : l’API Navigation et le bouton Retour
Deux règles de routage surprennent les équipes qui implémentent des transitions d’affichage dans des routeurs côté client.
Au préalable de l’animation, React attend que la navigation en cours soit finalisée. Les routeurs qui suspendent l’événement navigate de l’API Navigation jusqu’à après le rendu doivent libérer cette promesse à l’intérieur de useLayoutEffect. Effectuer cette libération dans useEffect crée un cycle : React attend la navigation, la navigation attend un effet planifié uniquement après la transition, et les outils de développement avertissent que la navigation est bloquée.
L’historique basé sur le classique popstate refuse également les transitions animées. Ces navigations doivent s’effectuer de manière synchrone afin que le défilement et la restauration des formulaires restent corrects, et un commit synchrone ne peut pas gérer de transition d’affichage — c’est pourquoi React omet l’animation. Préférez l’écouteur navigate de l’API de navigation, qui permet de gérer les actions avant/arrière de manière asynchrone :
useEffect(() => {
if (!window.navigation) return; // Chrome 102+, Safari 26.2, Firefox 147
const onNavigate = (event) => {
if (!event.canIntercept) return;
event.intercept({
handler() {
return new Promise((resolve) => {
startTransition(() => {
addTransitionType(event.navigationType === 'traverse' ? 'navigation-back' : 'navigation-forward');
setPath(new URL(event.destination.url).pathname);
});
resolveWhenPainted.current = resolve; // resolved from a useLayoutEffect after commit
});
},
});
};
window.navigation.addEventListener('navigate', onNavigate);
return () => window.navigation.removeEventListener('navigate', onNavigate);
}, []);
Les routeurs en environnement de production filtrent également les changements d’hash au sein du même document, les téléchargements et les cibles跨-origin ; on s’attend à ce que des bibliothèques fournissent cette intégration plutôt que de demander à chaque application de la réinventer.
Méthode manuelle antérieure
Au temps de ces événements, les équipes utilisaient encore WAAPI avec l’option pseudoElement pour gérer les pseudo-éléments :
const vt = document.startViewTransition(() => flushSync(update));
await vt.ready;
document.documentElement.animate(
[{ transform: 'translateY(24px)' }, { transform: 'none' }],
{ duration: 380, pseudoElement: '::view-transition-new(toast)' }
);
Cette approche imposait l’utilisation de noms uniques manuellement, vous obligeant à déduire quel déclencheur s’était activé puisque React restait silencieux, et nécessitait une gestion précise du timing par rapport à ready. En revanche, les props d’événement fournissent automatiquement un nom indiquant quel déclencheur a été utilisé, quels types s’appliquent, ainsi qu’un hook qui se déclenche une fois que les captures d’écran sont prêtes.
Préférez conserver les effets de transition rapides et les modifications de mise en page dans le CSS, et réservez les callbacks d’événement aux valeurs qui nécessitent vraiment des closures — indices, positions de pointeur, paramètres de ressort ou durées définies par des flags fonctionnelles. Cette séparation permet de conserver des feuilles de style compatibles avec DevTools pour le cas courant, tout en conservant la possibilité d’utiliser WAAPI lorsque les variables CSS sur le DOM en temps réel n’atteignent jamais la couche des captures d’écran.
Lorsque les démos partagent un curseur ou une zone de recherche avec la liste animée, placez l’état dans le même composant qui rend <ViewTransition>. La fermeture d’événement lit alors des valeurs fraîches sans recourir à un registre global d’animation. Si plusieurs limites doivent s’exécuter de manière séquentielle, transmettez l’index en tant que propriété à un petit enfant qui possède son propre onEnter, plutôt que de calculer des délais dans un parent incapable de voir chaque instance instantanée.
Pourquoi c’est important
Les systèmes d’animation déclaratifs ont finalement besoin d’une issue de secours impérative. La conception de cette issue détermine si les équipes abandonnent la bibliothèque dès le premier cas limite. Ici, l’interface est simple : quatre fonctions de rappel et un objet d’instance ; elle se compose avec le CSS au lieu de le remplacer, et le contrat de nettoyage empêche que des animations interrompues ne laissent des snapshots figés.
La logique d’animation se trouve également à côté de l’élément qui la possède, dans le même contexte d’état que ce composant. Il n’existe pas de registre global des noms ni de recherche de références après le montage.
Ponts à franchir
- Retournez toujours une fonction de nettoyage. Oublier
return () => anim.cancel()fait en sorte qu’une capture d’écran reste affichée jusqu’à la fin de sa propre chronologie, même en cas d’interruption. - Les événements s’exécutent après les valeurs par défaut. Les transitions entre navigateurs et les keyframes de la View Transition Class ont déjà été appliqués ; animer
opacityen JS se superpose à eux, sauf si le CSS définitanimation: nonesur ce pseudo-élément. - Un événement par bordure et par transition. Les éléments partagés déclenchent uniquement
onShare— pas à la fois l’entrée et la sortie.
instance.old existe. Appeler instance.old.animate() dans onEnter vise un pseudo-élément que le navigateur n’a jamais créé, ce qui entraîne une action silencieuse sans effet. Animez new à l’entrée et old à la sortie.flushSync saute complètement la transition, de sorte que les événements ne sont jamais déclenchés. Un document caché fait de même.matchMedia('(prefers-reduced-motion: reduce)') à l’intérieur du callback ; la requête média CSS ne contrôle pas les chronologies WAAPI.popstate ne provoque jamais d’animation. Vérifiez la prise en charge de window.navigation avant d’espérer une historique animée.Si vous ne pouvez pas encore l’utiliser
Le chemin WAAPI manuel fonctionne avec n’importe quelle version de React dans les navigateurs qui prennent en charge document.startViewTransition. Il faut l’encadrer une seule fois :
import { flushSync } from 'react-dom';
export async function transitionWith(update, animateFn) {
if (!document.startViewTransition) { update(); return; }
const vt = document.startViewTransition(() => flushSync(update));
await vt.ready;
const anim = animateFn((pseudo) => ({
animate: (kf, opts) => document.documentElement.animate(kf, { ...opts, pseudoElement: pseudo }),
}));
vt.finished.finally(() => anim?.cancel());
}// usage — you own the names
transitionWith(
() => setShow(true),
(get) => get('::view-transition-new(toast)').animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300 })
);
Vous perdez les informations relatives au déclencheur et aux types, et vous devez définir vous-même view-transition-name. Lors d’une mise à niveau, le corps de animateFn est généralement déplacé dans onEnter avec peu de modifications.
Ressources
- La documentation React
<ViewTransition>sur les événements, l’animation JavaScript et l’intégration avec le routeur - Les directives
addTransitionTypepour personnaliser les animations via des événements - MDN
Element.animate()et l’optionpseudoElement - La référence de l’API de navigation MDN
Les équipes qui utilisent React 19.3 pour les transitions d’affichage doivent considérer l’API des événements comme une solution de secours, et non comme la méthode par défaut. Commencez par les classes et les types ; n’utilisez onEnter / onExit / onUpdate / onShare que lorsque des calculs ou des closures sont inévitables. Cette discipline permet de garder la plupart des animations visibles dans le panneau Styles, tout en couvrant les cas que CSS ne peut pas exprimer.