Animating Suspense Reveals with ViewTransition: Fallbacks, Images, Fonts
Learn how wrapping Suspense in ViewTransition animates the skeleton-to-content swap, which props stop redundant animations, and how React waits for images and fonts.
This article is published in English.
Sources: React 19.3 View Transitions with Suspense: Animate the Fallback, Wait for Images and Fonts
A Suspense boundary already describes a transition: it shows a fallback, then swaps in the content. Historically that swap was a hard cut, often followed by an image popping in a moment later and a web font swapping in after that. React 19.3 lets you wrap the boundary in <ViewTransition> so the swap becomes an animation, and, less obviously, makes React delay that animation until pictures and web fonts in the incoming content have loaded. This guide shows how to set that up, how to stop the animation from firing when it should not, where to place the boundaries, and what the built-in waits actually do.
Because this behavior shipped recently, at the time of writing, confirm the details against the current React documentation before relying on exact timings in production. The examples assume you already know that <ViewTransition> animates on specific triggers (enter, exit, update) and that it has to wrap the DOM it animates.
Turning the reveal into an animation
The minimal version simply puts a <ViewTransition> around the Suspense boundary:
<ViewTransition>
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
You do not need startTransition for the swap, since React already treats revealing a Suspense boundary as a Transition. Once Forecast finishes suspending, the commit that replaces the skeleton runs inside a view transition. Since the skeleton and the forecast live inside the same <ViewTransition>, they share one generated snapshot name, so the change is treated as an update and the browser cross-fades from one to the other.
A runnable forecast example
The demo starts with the imports it needs:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
The component code caches one Promise per city in a Map, because use() needs to receive the same Promise instance across renders; creating a new one every render would suspend forever. Forecast reads the resolved data with use(), Skeleton renders a placeholder card, and App toggles the whole boundary inside 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>
)}
</>
);
}
A couple of CSS rules style the card and give the skeleton a neutral background, using light-dark() for both color schemes:
.card { margin-top: 12px; padding: 14px 16px; border-radius: 10px; background: light-dark(#e0f2fe, #0c3350); }
.skeleton { color: transparent; background: light-dark(#e2e8f0, #2a2f3a); }
A final rule slows the default cross-fade so the change is easy to see while you experiment:
/* slow the default cross-fade so the swap is visible */
::view-transition-old(*), ::view-transition-new(*) { animation-duration: 450ms; }
Clicking the button shows the skeleton, waits roughly a second for the simulated fetch, and then fades the forecast in over the skeleton. Hide it and load it again, though, and you will notice a problem: the data is now cached, so no fallback appears, but the card fades in anyway, just as it faded out when hidden. That leads to the next section.
Stopping animations you did not ask for
A <ViewTransition> with no props animates every trigger: enter on mount, exit on unmount and update whenever the content inside changes. Wrapped around Suspense, that produces up to three animations when only one was intended. React's documentation spells out the desired behavior for loading UI:
- The fallback should appear immediately and without animation, because the user just acted and expects instant feedback.
- The change from fallback to content should animate.
- Content that is already cached, and therefore never suspends, should show up instantly and unanimated.
Two props express exactly that. default="none" turns every trigger off, and update="auto" turns the one you care about back on:
<ViewTransition update="auto" default="none">
<Suspense fallback={<Skeleton />}>
<Forecast city={city} />
</Suspense>
</ViewTransition>
Comparing the two configurations
The second demo uses the same imports:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
It lets you load one of three cities and toggle between the bare <ViewTransition> and the two-prop version. Note the key={city} on the <ViewTransition>: switching cities mounts a new boundary rather than updating the existing one.
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>
)}
</>
);
}
The stylesheet adds a flexible toolbar and the same card, skeleton and slowed cross-fade rules:
.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; }
With "only animate updates" enabled, a city that has not been loaded yet shows its skeleton instantly and then animates into content, while a previously loaded city simply shows up, unanimated. The docs recommend animating Suspense reveals only occasionally, and not at all for cached content that could have rendered at once, because there the animation is just added latency. In practice, update="auto" default="none" belongs on nearly every <ViewTransition> that wraps a Suspense boundary.
Placement decides which triggers fire
Where you put the <ViewTransition> relative to Suspense controls which triggers run, and so which parts you can animate separately.
Outside the boundary: one update
Wrapping Suspense from the outside creates a single boundary that sees the change as an update:
<ViewTransition update="cross">
<Suspense fallback={<A />}>
<B />
</Suspense>
</ViewTransition>
Fallback and content share one snapshot name, so all you can do is cross-fade or morph between them, here with a custom cross class.
Inside both branches: exit plus enter
Wrapping the fallback and the content separately creates two independent boundaries:
<Suspense
fallback={<ViewTransition exit="out"><A /></ViewTransition>}
>
<ViewTransition enter="in"><B /></ViewTransition>
</Suspense>
The fallback leaves with an exit animation and the content arrives with an enter animation, which allows the placeholder to drop away downwards while the real content rises into place.
A profile card using the inside placement
The third demo again starts from the imports:
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
loadProfile creates a fresh Promise on each click, so the boundary suspends every time. Profile and Skeleton each wrap their markup in their own <ViewTransition>, using enter="rise" and exit="sink" respectively with default="none". The key={attempt} on Suspense matters: it creates a new boundary for every load, because an already-mounted boundary that suspends again inside a Transition keeps displaying the previous content instead of showing the fallback.
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>
)}
</>
);
}
The base styles give the card and skeleton their look:
.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); }
The animation rules target the named classes. ::view-transition-old(.sink) applies to the outgoing skeleton snapshot and ::view-transition-new(.rise) to the incoming content, each with its own 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; } }
Waiting for images, fonts and stylesheets
Suspense boundaries have long held back for data and for lazy-loaded code. In React 19.3 the set of things a boundary holds back for grows when the reveal is being animated by a <ViewTransition>:
- Data read with
use()and lazy components: awaited in both cases. - Stylesheets declared with
<link rel="stylesheet" precedence="…">: awaited in both cases (with a timeout for plain Suspense). - Fonts that the new content introduces: ignored by plain Suspense; awaited, with a time limit, during a view transition update.
- Visible
<img>elements: ignored by plain Suspense; during a view transition update React waits for decoding for up to 800 ms, except for images that useloading="lazy"or have anonLoadhandler.
The rationale is visual. Animating into a card whose photo is only partly drawn, or whose text is still in a system font, looks worse than a plain cut. React therefore postpones a pending view-transition reveal until the pictures and new fonts in that boundary are available. Without a view transition nothing changes, and images keep popping in whenever they arrive.
How long React is willing to wait
The waits are bounded. These specifics come from reading the react-dom implementation rather than from the documentation, so treat them as implementation details that may change:
- For images, React calls
img.decode()and delays the commit by at most 800 ms. - It also guesses how heavy each image is from its
widthandheightattributes, and how fast the network is from Resource Timing entries. When that estimate says the downloads would take longer than roughly 500 ms, React gives them just 50 ms and then reveals regardless. - Without
widthandheight, the estimate assumes a 100×100 image, so real dimensions make the estimate meaningful. - For fonts, the limit is about 500 ms.
Demonstrating the image wait
The image demo imports a few extra hooks:
import { ViewTransition, Suspense, use, useState, useRef, useLayoutEffect, startTransition } from 'react';
Each click fetches a listing with a randomly seeded photo URL, so the image is never cached. Two slots render the same card side by side. The left one lets React wait for the photo; the right one attaches an empty onLoad handler, which opts the image out, so the card reveals immediately and the photo arrives afterwards. A useLayoutEffect logs img.complete at the moment each card commits, so you can confirm the difference on a fast network where it is hard to see.
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>
);
}
The styles lay out the two columns and reserve the image's aspect ratio in both the card and the 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; }
Fonts as trackable resources
Fonts follow the same model. The trick is to declare the @font-face rule in a <style> element with both href and precedence, which tells React to hoist it and track it as a resource that the boundary can wait for:
<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>
The React documentation includes a fuller example that puts fetched data, a precedence stylesheet, a custom font and a portrait photo behind one shared skeleton, all revealed together. That combination matches most real cards: a profile, a product tile, a chat message with an avatar.
How this used to be done
Before this, holding content until an image was ready required manual preloading: create an Image, wait for onload, then flip a piece of state. A typical hook looked like this:
function useImageReady(src) {
const [ready, setReady] = useState(false);
useEffect(() => {
const img = new Image();
img.onload = () => setReady(true);
img.src = src;
}, [src]);
return ready;
}
Each component carried its own copy and none of them coordinated, so a card with both a photo and a custom font still revealed in two visible steps. Fonts were worse still, requiring document.fonts.load() plus a flag, or font-display: block with its own flash of invisible text. Animating the swap meant calling startViewTransition manually when data arrived, and because that went through flushSync, it could not wait for anything itself.
Why the change matters
Loading UI becomes one decision rather than four. The Suspense boundary you already place now also determines when content counts as ready, spanning data, code, styles, fonts and images, and the animation runs once, at that moment. Per-resource effects disappear, along with the glue code that tried to synchronize them.
It also gives React a say in when the browser paints a loaded image for the first time. Previously an <img> painted as soon as it decoded. Linking it to the view transition produces the "appears complete" behavior of native apps without hand-written preloading.
Gotchas to keep in mind
- Fonts and images are awaited only during a view transition update. Without a
<ViewTransition>, or withupdate="none", images pop in as before. - Adding an
onLoadhandler excludes that image from the wait, view transition or not. That is useful for below-the-fold images, but surprising if the handler was added for analytics. - The limits are about half a second for fonts, 800 ms for images, and 50 ms whenever React predicts an image is too big for the current network. A sluggish CDN can postpone the reveal a little but never stall it.
- Images outside the viewport are ignored for this purpose.
- Cached content animates again by default, so pair Suspense with
update="auto" default="none". - With the outside placement, a skeleton and content of different heights force the browser to resize the snapshot, which reads as a visual stretch. Size the placeholder like the final content, or switch to the inside placement so the two animate on their own.
- Transition Types are reset after every commit, so a type such as
nextorbackadded to the update that showed the fallback no longer exists when the real content is committed.
A fallback for earlier React versions
If you cannot upgrade yet, the dependable way to avoid image pop-in is to treat the picture as part of what you are loading: suspend on a Promise that settles only once the JSON has arrived and the photo has been decoded:
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;
});
}
The .catch(() => {}) on decode() ensures a broken image cannot block the reveal forever. You still get an abrupt swap instead of an animation, yet the image sits in the cache by render time and appears in the same frame as the rest of the card. Fonts can be handled similarly by chaining on the Promise from document.fonts.load('1em Quote').
Key takeaways
- Wrapping Suspense in
<ViewTransition>animates the fallback-to-content swap without an explicitstartTransitionfor the reveal. - Use
update="auto" default="none"so fallbacks and cached content appear instantly and only the real reveal animates. - Put the
<ViewTransition>outside Suspense for a single cross-fade, or inside both branches for independent exit and enter animations. - During a view transition, React also waits, within tight limits, for visible images and new fonts, which removes most hand-written preloading.
- Give images real
widthandheight, and remember thatonLoadopts them out.
Primary references: the React 19.3 release notes, the Suspense reference, the ViewTransition reference and the style component reference.