Skip to content

React ViewTransition in 19.3, Measured in a Real Browser

React 19.3.1 ViewTransition driven by Playwright in Chromium 151: Suspense reveals, addTransitionType, reduced motion, and what startViewTransition got.

· · 9 min read
Bar chart of click-to-settled time for a Suspense reveal: plain, one ViewTransition, and enter and exit classes

Quick Take

ViewTransition went stable in React 19.3, and the docs describe it well. They don't say how many times React calls startViewTransition, what a Suspense reveal costs, or which class a transition type picks. So I wrapped the browser call and counted.

I ran everything below on React and react-dom 19.3.1, Chromium 151.0.7922.34 under Playwright 1.62.1, Node 22.23.3, on an Apple M1 that was busy with other jobs (one-minute load average between 14 and 19 on eight cores). Counts and class names don't care about load. Millisecond figures do, so read them as medians of five fresh pages, not as a promise.

TL;DR: ViewTransition is stable in 19.3 and startViewTransition is called by React, never by you. A plain setState never triggers it. Wrapping a Suspense boundary costs two browser transitions, not one, and addTransitionType reaches the fallback but not the reveal. React won't honor prefers-reduced-motion for you.

What Does 19.3 Actually Export?

The release post says ViewTransition shipped as an experimental API last year and is stable in 19.3, which landed on September 9, 2026. I didn't want to take "stable" on faith for the two names I care about, so the harness imports three versions side by side:

ReactViewTransitionaddTransitionType
19.2.9nono
19.3.0yesyes
19.3.1yesyes

If you're still on a 19.2.x patch, neither name exists. Bump to 19.3 first. The React 19 feature guide covers the rest of what the 19.3 line added, and this article stays on one component.

There's one more thing worth knowing before any code. ViewTransition only works in the DOM. The release post says React Native support is being worked on, and nothing more.

When Does React Call the Browser API?

Not as often as you'd guess. The reference page is explicit that React calls startViewTransition itself, "so you should never do that yourself", and that only three kinds of update qualify: a Transition, a Suspense reveal, or useDeferredValue. A bare setState doesn't.

So I tested that sentence. The harness wraps document.startViewTransition before React loads and logs every call. A stepper with a ViewTransition around its content gave me this:

ClickCalls to startViewTransition
setStep(s => s + 1) outside a transition0
the same update inside startTransition1

Same state, same markup, same boundary. The only difference is whether React considered the update interruptible. That's the first thing to check when somebody tells you "ViewTransition does nothing": open the console, wrap the browser method like I did, and see whether it was ever called.

What Does a Suspense Reveal Cost?

Here is the setup. A button calls startTransition(() => setOpen(true)), which mounts a Suspense boundary around a component that reads a promise with use(). The promise resolves after 50, 300 or 1000 ms. I wrapped it three ways.

// 1. plain: no ViewTransition anywhere
<Suspense fallback={<Skeleton />}>
  <Panel k={key} />
</Suspense>

// 2. "update": one wrapper, fallback and content share a name
<ViewTransition>
  <Suspense fallback={<Skeleton />}>
    <Panel k={key} />
  </Suspense>
</ViewTransition>

// 3. "enter/exit": the fallback leaves, the content arrives
<Suspense fallback={<ViewTransition exit="reveal-out"><Skeleton /></ViewTransition>}>
  <ViewTransition enter="reveal-in"><Panel k={key} /></ViewTransition>
</Suspense>

Patterns 2 and 3 come straight from the Suspense section of the reference. The CSS for the third is two rules: ::view-transition-new(.reveal-in) runs a 400 ms rise, ::view-transition-old(.reveal-out) a 120 ms fall. Times below are measured from the click.

WrappingData afterstartViewTransition callsContent in the DOMEverything settled
plain50 ms0358 ms358 ms
plain1000 ms01062 ms1062 ms
update50 ms2394 ms674 ms
update1000 ms21061 ms1332 ms
enter/exit50 ms2393 ms812 ms
enter/exit1000 ms21055 ms1479 ms

Three things jump out.

Two calls, always. Showing the fallback is one view transition, revealing the content is another. If you wrote one ViewTransition and expected one animation, you got two, and the first is the skeleton fading in.

The 358 ms row. Data arrived after 50 ms and the content still took 358 ms. That isn't ViewTransition. The plain version has no ViewTransition at all. React's own source defines FALLBACK_THROTTLE_MS = 300 in react-dom 19.3.1, which matches the 358 ms: a fresh fallback stays up for about that long before the content replaces it. Fast data gets a 300 ms skeleton whether you animate it or not, which makes the fade-in of that skeleton a strange thing to polish.

The animation tail. With slow data the reveal itself adds about 270 ms to the update pattern (1332 against 1061) and 424 ms to the enter/exit pattern, because my custom rise runs 400 ms. With fast data the transition also pushed the content into the DOM 35 or 36 ms later than the plain run. That fits the reference page's statement that React waits for a running view transition to finish before it starts the next one. I didn't instrument the wait itself, so treat the explanation as a likely cause rather than a measured one.

Does a 400 ms rise feel good? I don't think so. The browser's default fade on these pseudo-elements measured 250 ms on every boundary I left unstyled, and I'd keep reveals at or under that.

A Suspense boundary wrapped in ViewTransition makes two browser transitions, so budget for the fallback's fade-in too, not just the reveal.

Share this Post on X Bluesky

How Does addTransitionType Pick the Animation?

You call it inside startTransition, next to the state update, and the ViewTransition props take an object keyed by type. The stepper below slides content in from the side the user is heading toward.

function go(dir) {
  startTransition(() => {
    addTransitionType(dir); // 'next' or 'previous'
    setStep((s) => (dir === 'next' ? s + 1 : s - 1));
  });
}

<ViewTransition
  key={step}
  default="none"
  enter={{ next: 'from-right', previous: 'from-left', default: 'none' }}
  exit={{ next: 'to-left', previous: 'to-right', default: 'none' }}
>
  <div className="panel">Step {step}</div>
</ViewTransition>

The class names are plain CSS, each a single ::view-transition-new(.from-right) style rule with a 180 ms keyframe. Here is what the browser reported after each click:

ClickTypes passed to the browserAnimations on React's named boundaries
Next['next']old: to-left 180 ms, new: from-right 180 ms
Previous['previous']old: to-right 180 ms, new: from-left 180 ms
Next, but addTransitionType never callednonenone

The last row is the one I'd have gotten wrong from the docs alone. With default="none" and no type added, the transition still ran (one call), but no named boundary animated. The addTransitionType reference says a none match disables the boundary without assigning it a name, and the harness agreed: the list of named animations came back empty. That's a good default for a component that sits in an app with other transitions, because nothing moves unless the cause is on your list.

Types also reach CSS directly. React adds each one as a browser view transition type, so :root:active-view-transition-type(next) works. The Chrome page's support box lists the types feature at Chrome 125, Firefox 144 and Safari 18. In my run, the set on the ViewTransition object read ['next'] once ready resolved.

Now the catch. The addTransitionType reference says types reset after every commit, so a Suspense fallback picks them up and the later reveal doesn't. I checked that too, by adding the type open to the update that mounts a boundary:

CommitTypes
fallback appears['open']
content revealsnone

If your design needs "slide in from the right" to apply to both halves, don't key it on a type. Put the class on the enter prop directly and keep types for the cause of navigation, which is what they're for.

What About prefers-reduced-motion?

React doesn't handle it. The reference page tells you to use the media query yourself. I wanted to see what that means in practice, so I ran the stepper under Playwright's reducedMotion: 'reduce' with nothing else changed. The custom 180 ms slide still ran. Then I added this to the page:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

After that the named boundaries had no animations at all, and the whole transition finished in about 49 ms instead of roughly 300 ms. The Chrome documentation adds a caveat worth repeating: reduced motion doesn't necessarily mean no motion, and a gentler cross-fade can still show how elements relate. I used none because it's the one rule I could verify in a single line. Your design may want a short fade instead.

Why does the transition take about 300 ms when my slide is only 180? Because the root snapshot still runs the browser's default 250 ms fade-out, and the transition lasts as long as its longest animation.

What I'd Ship

Opinions, since the data allows a few. Use the update pattern, one wrapper, as the default for lazy panels. The enter/exit pattern is worth it only when the skeleton and the content differ enough that a cross-fade looks like a mistake. Keep default="none" on any boundary inside a shared layout so route-level transitions don't make it dance. Put the reduced-motion rule in your global stylesheet once.

And don't skip the back button. The reference says React skips animations from popstate, so a router that doesn't use the Navigation API will animate forward and snap on back. That's easy to miss in a demo that only has a Next button.

The browser side of this story, including the cross-document version for multi-page sites, lives in the View Transitions API guide. If the thing you're animating is a lazy chunk rather than data, the code splitting guide for React 19 covers where the boundary should sit, and Suspense data fetching patterns explains why the promise in my harness is cached outside the component.

Run It Yourself

The harness is blogs/coding-dunia/examples/react-view-transition-measured/ in the repository. npm install, npx playwright install chromium, npm test. It asserts every count and class name in this article and prints the timings. Mine will differ from yours. The counts shouldn't.

Frequently Asked Questions

Is ViewTransition stable in React 19.3?
Yes. The React 19.3 release post, dated September 9, 2026, says it was shared as an experimental API last year and is stable and ready to use in 19.3. Both ViewTransition and addTransitionType are exported from react 19.3.0 and 19.3.1. Neither is exported from react 19.2.9, which I checked by importing that version directly.
Why doesn't my ViewTransition animate when I call setState?
React only starts a view transition for an update marked as a Transition, for a Suspense reveal, or for useDeferredValue. A plain setState commits immediately and never touches the browser API. In the harness for this article, a plain setState click produced zero document.startViewTransition calls, while the same state change inside startTransition produced one.
Does addTransitionType carry over to the Suspense reveal?
No. Transition types are reset after each commit. With the type 'open' added to the update that mounted a Suspense boundary, the fallback commit called startViewTransition with types ['open'] and the later reveal called it with no types at all. If the reveal needs its own styling, give it a ViewTransition class that doesn't depend on a type.
Does React turn view transitions off for prefers-reduced-motion?
No. The React docs say so, and Chromium agreed: with reduced motion emulated and no CSS, the custom enter and exit animations still ran. Adding a prefers-reduced-motion media query that sets animation to none on the view-transition pseudo-elements left no named animations and the transition finished in about 49 ms.