Animer la révélation du suspense avec ViewTransition : solutions de remplacement, images, polices
Apprenez comment l’enveloppement de Suspense dans ViewTransition anime le changement du squelette vers le contenu, comment les props empêchent des animations redondantes, et comment React attend la chargement des images et des polices.
Sources: React 19.3 View Transitions with Suspense: Animate the Fallback, Wait for Images and Fonts
Une frontière de suspense décrit déjà une transition : elle affiche un contenu de secours, puis remplace celui-ci par le contenu réel. Historiquement, ce remplacement se faisait par un coup net, suivi souvent d’une image qui apparaissait peu après et d’un police web qui s’affichait à son tour. React 19.3 permet d’envelopper cette frontière dans <ViewTransition> afin que le remplacement se fasse sous forme d’animation ; de plus, et de manière moins évidente, il fait en sorte que React diffère cette animation jusqu’à ce que les images et les polices web du contenu à afficher aient été chargées. Ce guide montre comment configurer cela, comment empêcher l’animation de s’exécuter lorsque ce n’est pas nécessaire, où placer les frontières, et quel est le rôle réel des fonctions de attente intégrées.
Puisque ce comportement a été intégré récemment, au moment de la rédaction de ce texte, veuillez vérifier les détails dans la documentation actuelle de React avant de vous fier à des temps précis en environnement de production. Les exemples partent du principe que vous savez déjà que <ViewTransition> anime en réponse à des déclencheurs spécifiques (enter, exit, update) et qu’il doit entourer le DOM qu’il anime.
Transformer la révélation en animation
La version minimale consiste simplement à placer un <ViewTransition> autour de la limite du Suspense :
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Vous n’avez pas besoin de startTransition pour l’échange, car React traite déjà la mise en évidence d’une frontière Suspense comme une transition. Une fois que Forecast a terminé de suspendre le contenu, l’opération qui remplace le squelette s’exécute au sein d’une transition de vue. Comme le squelette et la prévision se trouvent à l’intérieur de la même <ViewTransition>, ils partagent le même nom d’aperçu généré, de sorte que le changement est considéré comme une mise à jour et le navigateur effectue un fondu entre les deux.
Un exemple de prévision fonctionnel
La démonstration commence par les importations nécessaires :
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
Le code du composant met en cache une Promise par ville dans un Map, car use() doit recevoir la même instance de Promise à chaque rendu ; créer une nouvelle instance à chaque rendu entraînerait un blocage indéfini. Forecast lit les données résolues à l’aide de use(), Skeleton affiche une carte de remplacement, et App active ou désactive l’ensemble du cadre à l’intérieur de startTransition:
// use() needs the same Promise instance across renders, so cache it
const cache = new Map();
function fetchForecast(city) {
if (!cache.has(city)) {
cache.set(city, new Promise(resolve =>
setTimeout(() => resolve({ city, high: 31, low: 24, sky: '⛅' }), 1100)
));
}
return cache.get(city);
}function Forecast({ city }) {
const f = use(fetchForecast(city));
return <div className="card">{f.sky} {f.city}: {f.high}° / {f.low}°</div>;
}function Skeleton() {
return <div className="card skeleton">Loading forecast…</div>;
}export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => startTransition(() => setShow(s => !s))}>
{show ? 'Hide' : 'Load'} forecast
</button>
{show && (
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city="Pune" />
</Suspense>
</ViewTransition>
)}
</>
);
}
Quelques règles CSS stylisent la carte et donnent au squelette un arrière-plan neutre, en utilisant light-dark() pour les deux schémas de couleurs :
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
Une règle finale ralentit le fondu par défaut afin que les changements soient facilement visibles pendant l’expérimentation :
/* slow the default cross-fade so the swap is visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Clicquer sur le bouton affiche l’arrière-plan squelette, attend environ une seconde pour la requête simulée, puis fait apparaître progressivement les prévisions par-dessus cet arrière-plan. Cependant, si vous le cachez puis le chargez à nouveau, vous remarquerez un problème : les données sont maintenant mémorisées en cache, donc aucun plan de secours n’apparaît, mais la carte apparaît néanmoins progressivement, exactement comme elle disparaissait lorsqu’elle était cachée. Cela nous mène à la section suivante.
Arrêter les animations non sollicitées
<ViewTransition> sans propriétés anime chaque déclencheur : enter lors du montage, exit lors du démontage et update chaque fois que le contenu à l’intérieur change. Lorsqu’il est utilisé avec Suspense, cela génère jusqu’à trois animations alors qu’une seule était prévue. La documentation de React décrit clairement le comportement souhaité pour le chargement de l’interface utilisateur :
- Le contenu de secours doit apparaître immédiatement et sans animation, car l’utilisateur vient d’agir et s’attend à une réponse instantanée.
- Le passage du contenu de secours au contenu réel doit être animé.
- Le contenu déjà en mémoire cache, et donc qui ne provoque jamais de suspension, doit apparaître immédiatement sans animation.
Deux propriétés expriment précisément cela. default="none" désactive tous les déclencheurs, tandis que update="auto" réactive celui qui vous intéresse :
<ViewTransition update="auto" default="none">
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Comparaison des deux configurations
La deuxième démonstration utilise les mêmes importations :
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
Cela vous permet de charger l’une des trois villes et de basculer entre la version simple <ViewTransition> et la version à deux propriétés. Notez key={city} sur <ViewTransition> : le changement de ville crée une nouvelle frontière au lieu de mettre à jour l’existante.
const cache = new Map();
function fetchForecast(city) {
if (!cache.has(city)) {
const data = { Pune: '⛅ 31° / 24°', Goa: '🌧 29° / 26°', Shimla: '☀️ 18° / 9°' }[city];
cache.set(city, new Promise(r => setTimeout(() => r(data), 1100)));
}
return cache.get(city);
}function Forecast({ city }) {
const text = use(fetchForecast(city));
return <div className="card">{city}: {text}</div>;
}export default function App() {
const [city, setCity] = useState(null);
const [onlyUpdates, setOnlyUpdates] = useState(false);
const props = onlyUpdates ? { update: 'auto', default: 'none' } : {}; return (
<>
<div className="bar">
{['Pune', 'Goa', 'Shimla'].map(c => (
<button key={c} onClick={() => startTransition(() => setCity(city === c ? null : c))}>
{city === c ? 'Hide' : 'Load'} {c}
</button>
))}
<label><input type="checkbox" checked={onlyUpdates} onChange={e => setOnlyUpdates(e.target.checked)} /> only animate updates</label>
</div>
{city && (
<ViewTransition key={city} {...props}>
<Suspense fallback={<div className="card skeleton">Loading {city}…</div>}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
)}
</>
);
}
Le fichier de style ajoute une barre d’outils flexible ainsi que les mêmes règles pour les cartes, les squelettes et les effets de fondu ralenti :
.bar { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Lorsque l’option « only animate updates » est activée, une ville qui n’a pas encore été chargée affiche immédiatement son squelette avant de se transformer progressivement en contenu animé, tandis qu’une ville déjà chargée apparaît simplement, sans animation. La documentation recommande d’animer les affichages différés uniquement occasionnellement, et pas du tout pour le contenu en cache qui aurait pu être affiché immédiatement, car l’animation ne fait qu’ajouter un délai supplémentaire. En pratique, update="auto" default="none" doit être utilisé dans presque tous les <ViewTransition> qui entourent une zone de différé.
Le positionnement détermine quelles actions sont déclenchées
L’endroit où vous placez le <ViewTransition> par rapport à la zone de différé contrôle quelles actions sont exécutées, et donc quels éléments vous pouvez animer séparément.
Hors de la zone de différé : une seule mise à jour
En enveloppant le suspense depuis l’extérieur, on crée une seule frontière qui perçoit ce changement comme une mise à jour:
<ViewTransition update="cross">
<Suspense fallback={<A />}>
<B />
</Suspense>
</ViewTransition>
Le plan de secours et le contenu partagent le même nom de capture d’écran, ce qui signifie que tout ce qu’on peut faire, c’est effectuer un fondu ou une transformation entre eux, ici à l’aide d’une classe cross personnalisée.
Dans les deux branches : sortie puis entrée
En enveloppant séparément le plan de secours et le contenu, on crée deux frontières indépendantes :
<Suspense
fallback={<ViewTransition exit="out"><A /></ViewTransition>}
>
<ViewTransition enter="in"><B /></ViewTransition>
</Suspense>
Le plan de secours s’éloigne avec une animation exit tandis que le contenu arrive avec une animation enter, ce qui permet au placeholder de descendre tandis que le véritable contenu monte à sa place.
Une carte de profil utilisant l’agencement intérieur
La troisième démonstration commence à nouveau par les imports :
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
loadProfile crée une nouvelle Promise à chaque clic, ce qui fait que le mécanisme de suspension s’active à chaque fois. Profile et Skeleton enveloppent chacun leur markup dans leur propre <ViewTransition>, en utilisant respectivement enter="rise" et exit="sink" avec default="none". La valeur key={attempt} dans Suspense est importante : elle crée un nouveau mécanisme de suspension pour chaque chargement, car un mécanisme déjà activé qui se suspend à nouveau à l’intérieur d’une Transition continue d’afficher le contenu précédent au lieu de montrer le contenu de secours.
function loadProfile() {
// a fresh Promise per click, so it suspends every time
return new Promise(r => setTimeout(() => r({ name: 'Rahul', role: 'Frontend' }), 1100));
}function Profile({ promise }) {
const p = use(promise);
return (
<ViewTransition enter="rise" default="none">
<div className="card">👤 {p.name} — {p.role}</div>
</ViewTransition>
);
}function Skeleton() {
return (
<ViewTransition exit="sink" default="none">
<div className="card skeleton">loading…</div>
</ViewTransition>
);
}export default function App() {
const [attempt, setAttempt] = useState(0);
const [promise, setPromise] = useState(null);
return (
<>
<button onClick={() => startTransition(() => { setAttempt(a => a + 1); setPromise(loadProfile()); })}>
{attempt ? 'Load again' : 'Load profile'}
</button>
{promise && (
// key: a NEW boundary each time. A mounted boundary that re-suspends
// inside a Transition keeps showing the old content instead of the fallback.
<Suspense key={attempt} fallback={<Skeleton />}>
<Profile promise={promise} />
</Suspense>
)}
</>
);
}
Les styles de base donnent leur apparence à la carte et au squelette :
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: light-dark(#64748b, #94a3b8); background: light-dark(#e2e8f0, #2a2f3a); }
Les règles d’animation ciblent les classes nommées. ::view-transition-old(.sink) s’applique à l’aperçu du squelette sortant et ::view-transition-new(.rise) au contenu entrant, chacun disposant de ses propres keyframes :
::view-transition-old(.sink) { animation: 240ms ease-in both sink; }
::view-transition-new(.rise) { animation: 320ms ease-out both rise; }
@keyframes sink { to { transform: translateY(16px); opacity: 0; } }
@keyframes rise { from { transform: translateY(-16px); opacity: 0; } }
Attente des images, polices et feuilles de style
Déjà depuis longtemps, les limites de Suspense retiennent en arrière-plan les données ainsi que le code chargé de manière différée. Dans React 19.3, cet ensemble d’éléments retenu en arrière-plan s’agrandit lorsque la révélation est animée par un <ViewTransition> :
- Données lues avec
use()et composants différés : attendues dans les deux cas. - Feuilles de style déclarées avec
<link rel="stylesheet" precedence="…">: attendues dans les deux cas (avec un délai d’attente pour le Suspense classique).
<img> visibles : ignorés par Suspense classique ; lors d’une mise à jour de transition d’affichage, React attend la décodage pendant jusqu’à 800 ms, sauf pour les images utilisant loading="lazy" ou disposant d’un gestionnaire onLoad.Le principe est visuel. Animer l’apparition d’une carte dont la photo n’est que partiellement chargée, ou dont le texte utilise encore une police système, donne un résultat moins bon qu’un simple changement d’affichage. React reporte donc la révélation liée à une transition d’affichage en attente jusqu’à ce que les images et les nouvelles polices concernées soient disponibles. Sans transition d’affichage, rien ne change, et les images continuent d’apparaître dès leur chargement.
Pendant combien de temps React est-il prêt à attendre
Les délais d’attente sont limités. Ces spécificités proviennent de l’examen de la mise en œuvre de react-dom plutôt que de la documentation, il convient donc de les considérer comme des détails d’implémentation qui peuvent changer :
- Pour les images, React appelle
img.decode()et retarde l’exécution d’au plus 800 ms. - Il estime également la taille de chaque image en se basant sur ses attributs
widthetheight, ainsi que sur la vitesse du réseau à partir des données de timing des ressources. Lorsque cette estimation indique que le téléchargement prendrait plus de 500 ms environ, React ne lui alloue que 50 ms avant d’exécuter l’opération quoi qu’il arrive. - En l’absence des attributs
widthetheight, l’estimation suppose une image de 100×100 ; par conséquent, les dimensions réelles rendent cette estimation pertinente. - Pour les polices, la limite est d’environ 500 ms.
Démonstration du délai d’attente pour les images
La démonstration concernant les images importe quelques hooks supplémentaires :
import { ViewTransition, Suspense, use, useState, useRef, useLayoutEffect, startTransition } from 'react';
Chaque clic récupère une fiche contenant une URL d’image générée aléatoirement, de sorte que l’image n’est jamais mémorisée en cache. Deux cases affichent la même carte côte à côte. Celle de gauche permet à React d’attendre l’arrivée de la photo ; celle de droite utilise un gestionnaire onLoad vide, ce qui empêche l’affichage de l’image, de sorte que la carte s’affiche immédiatement tandis que la photo arrive par la suite. Un useLayoutEffect enregistre la valeur de img.complete au moment où chaque carte est affichée, ce qui permet de constater la différence sur un réseau rapide où il est difficile de la remarquer.
function fetchListing() {
return new Promise(r => setTimeout(() => r({
title: 'Sea-facing 2BHK, Bandra',
// a new seed every time → a fresh download every time
photo: `https://picsum.photos/seed/${Math.random().toString(36).slice(2)}/600/300`,
}), 300));
}function Listing({ promise, optOut, label }) {
const l = use(promise);
const img = useRef(null);
// runs at the moment the content is committed — was the photo already there?
useLayoutEffect(() => {
console.log(`${label}: photo decoded before reveal → ${img.current.complete}`);
}, [label]);
return (
<div className="card">
<img
ref={img}
src={l.photo}
width={600}
height={300}
alt=""
onLoad={optOut ? () => {} : undefined}
/>
<strong>{l.title}</strong>
</div>
);
}function Slot({ label, optOut }) {
const [promise, setPromise] = useState(null);
return (
<div>
<button onClick={() => startTransition(() => setPromise(fetchListing()))}>{label}</button>
{promise && (
<ViewTransition update="auto" default="none">
<Suspense fallback={<div className="card skeleton"><div className="ph" /><strong>loading…</strong></div>}>
<Listing promise={promise} optOut={optOut} label={label} />
</Suspense>
</ViewTransition>
)}
</div>
);
}export default function App() {
return (
<div className="two">
<Slot label="Wait for photo" optOut={false} />
<Slot label="onLoad opt-out" optOut={true} />
</div>
);
}
Les styles organisent les deux colonnes et conservent le rapport d’aspect de l’image tant dans la carte que dans le placeholder :
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.card { margin-top: 10px; padding: 10px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
.card img, .ph { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; border-radius: 6px; object-fit: cover; margin-bottom: 8px; }
.ph { background: light-dark(#e2e8f0, #363b47); }
.skeleton strong { color: light-dark(#64748b, #94a3b8); }
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Polices en tant que ressources traçables
Les polices suivent le même modèle. La astuce consiste à déclarer la règle @font-face dans un élément <style> en spécifiant à la fois href et precedence, ce qui indique à React de la placer en haut de la pile et de la considérer comme une ressource que le système peut attendre :
<ViewTransition update="auto" default="none">
<Suspense fallback={<QuoteSkeleton />}>
<style href={fontUrl} precedence="default">
{`@font-face {
font-family: 'Quote';
src: url(${fontUrl}) format('woff2');
font-display: swap;
}`}
</style>
<blockquote style={{ fontFamily: 'Quote' }}>{quote}</blockquote>
</Suspense>
</ViewTransition>
La documentation React propose un exemple plus complet montrant des données téléchargées, une feuille de style precedence, une police personnalisée et une photo en portrait regroupés derrière un même squelette commun, tous affichés ensemble. Cette combinaison correspond à la plupart des cartes réelles : un profil, une vignette de produit, un message de chat avec un avatar.
Comment cela se faisait auparavant
Auparavant, pour conserver du contenu en attendant que une image soit prête, il fallait effectuer un préchargement manuel : créer un Image, attendre onload, puis modifier l’état du système. Un hook typique ressemblait à ceci :
function useImageReady(src) {
const [ready, setReady] = useState(false);
useEffect(() => {
const img = new Image();
img.onload = () => setReady(true);
img.src = src;
}, [src]);
return ready;
}
Chaque composant disposait de sa propre copie et aucun d’eux ne coordonnait ses actions, ce qui faisait que une carte contenant à la fois une photo et une police personnalisée s’affichait encore en deux étapes visibles. La situation était encore pire pour les polices, qui nécessitaient soit document.fonts.load() accompagné d’un indicateur, soit font-display: block, ce qui provoquait une apparition temporaire de texte invisible. Pour animer le changement, il fallait appeler manuellement startViewTransition dès l’arrivée des données, et comme cette opération passait par flushSync, elle ne pouvait pas attendre quoi que ce soit d’autre.
Pourquoi ce changement est important
Le chargement de l’interface devient une seule décision plutôt que quatre. La limite définie par Suspense, déjà en place, détermine désormais quand le contenu est considéré comme prêt, englobant les données, le code, les styles, les polices et les images ; l’animation s’exécute alors une seule fois, à ce moment précis. Les effets liés à chaque ressource disparaissent, tout comme le code de liaison qui tentait de les synchroniser.
Cela permet également à React de déterminer quand le navigateur affiche pour la première fois une image chargée. Auparavant, un <img> était affiché dès sa décodage. Le lier à une transition d’affichage produit le comportement de « complétion immédiate » des applications natives, sans avoir besoin d’un préchargement manuel.
Points à garder en tête
- Les polices et les images ne sont attendues que lors d’une mise à jour due à une transition d’affichage. En l’absence de
<ViewTransition>, ou avecupdate="none", les images apparaissent comme avant. - L’ajout d’un gestionnaire
onLoadexclut cette image de l’attente, qu’il y ait ou non transition d’affichage. Cela est utile pour les images situées en dessous du fold, mais peut surprendre si le gestionnaire a été ajouté à des fins d’analyse.
update="auto" default="none".next ou back ajouté à la mise à jour affichant le fallback n’existe plus lorsque le contenu réel est commité.Un fallback pour les versions antérieures de React
Si vous ne pouvez pas encore mettre à niveau, la manière fiable d’éviter l’apparition soudaine des images est de les traiter comme faisant partie du contenu chargé : attendre une Promise qui ne se résoudra qu’une fois que le JSON sera arrivé et que l’image aura été décodée :
function loadListing() {
return fetch('/api/listing').then(r => r.json()).then(async data => {
const img = new Image();
img.src = data.photo;
await img.decode().catch(() => {}); // don't let a broken image block forever
return data;
});
}
La directive .catch(() => {}) appliquée à decode() empêche qu’une image endommagée ne bloque indéfiniment la mise en évidence du contenu. On obtient toujours un changement abrupt au lieu d’une animation, mais l’image est déjà présente dans le cache au moment de la rendu et apparaît dans le même cadre que le reste de la carte. Les polices peuvent être gérées de manière similaire en chaînant les promesses issues de document.fonts.load('1em Quote').
Points clés
- L’enveloppement de Suspense dans
<ViewTransition>anime le passage au contenu de secours sans nécessiter d’appel explicite àstartTransitionpour la mise en évidence. - Utilisez
update="auto" default="none"afin que les contenus de secours et ceux en cache apparaissent immédiatement, et que seule la véritable mise en évidence soit animée.
<ViewTransition> en dehors de Suspense pour un seul fondu, ou à l’intérieur des deux branches pour des animations d’entrée et de sortie indépendantes.width et height, et n’oubliez pas que onLoad les désactive.Références principales : les notes de version de React 19.3, la documentation sur Suspense, la documentation sur ViewTransition ainsi que celle sur le composant style.