← blog
ReactOctober 3, 2026 · 15 min read

React 19.3 ViewTransition: Animate State Without Losing It

React 19.3's ViewTransition component animates state changes, not just page swaps — the mental model, enter/exit/update/share, and a cheat sheet.

Parsa Jiravand · Frontend engineer · building bestpractic
React 19.3 ViewTransition: Animate State Without Losing It
  1. 1React Re-render vs Remount: What Actually Triggers Each13 min
  2. 2React Compiler 1.0: What useMemo You Can Delete13 min
  3. 3React Form Actions: useActionState & useFormStatus Guide13 min
  4. 4React Derived State: Why That useState Is Probably a Bug14 min
  5. 5React 19.3 ViewTransition: Animate State Without Losing Ityou are here

You wire up a photo grid. Click a thumbnail, it should morph smoothly into a full detail view — the kind of transition Google Photos and Instagram have trained everyone to expect. You already know the browser can do this for free: the native View Transitions API wraps a DOM update in document.startViewTransition(), tags the old and new elements with a shared view-transition-name, and the browser tweens between them. You wire it up. It works beautifully — right up until the thumbnail lives inside a filtered, paginated React list, and the click also has to update selectedId state, close a search box, and possibly wait on data that hasn't arrived yet. Now the animation glitches, fires on the wrong element, or doesn't fire at all, and nothing in the DevTools explains why.

This is episode five of React Deep Dive, on what React itself decides rather than JavaScript with a React import. This one is about a decision the reconciler now makes explicitly: which DOM changes are worth animating, and which four shapes every one of those changes takes.

This article is written against React 19.3 (verified against React's own CHANGELOG.md on GitHub, version 19.3.0, published September 9, 2026), where the <ViewTransition> component and addTransitionType shipped as stable, not experimental. Everything here assumes React 19-era function components and hooks.

By the end of this article you'll be able to:

  • Explain why a plain setState update never triggers a ViewTransition animation, and what has to wrap it instead
  • Use the four activation props — enter, exit, update, and share — and know which DOM change fires which one
  • Build a shared-element transition (thumbnail → detail) that survives a real re-render, not just a full-page navigation
  • Tag a transition with addTransitionType so the same component animates differently depending on why it changed
  • Recognize the cases <ViewTransition> can't help with — no Transition, no browser support, or content that's still loading

You've written function components with hooks and used useTransition or startTransition for pending UI at least once. No prior knowledge of the native View Transitions API is assumed, though if you've read the earlier piece on it, the mental model here builds directly on top of view-transition-name and the ::view-transition-old/::view-transition-new pseudo-elements.

The browser's native View Transitions API is beautifully simple for exactly one shape of change: a full document swap, or a DOM mutation you make yourself, synchronously, inside a callback:

JavaScript
1
2
3
4
5
6
7
8
9
10
// The "wrong way first" — wiring the native API up by hand inside a React app function selectPhoto(id) { if (!document.startViewTransition) { setSelectedId(id); // no support: just update, no animation return; } document.startViewTransition(() => { setSelectedId(id); // ⚠️ setState is async — the DOM hasn't changed yet }); }

startViewTransition() expects its callback to make the DOM change and finish before it takes its "after" snapshot. But setSelectedId doesn't touch the DOM — it schedules a re-render. By the time React actually commits the new list, the browser has already taken its snapshot and started animating between two identical frames. You can make this work by wrapping the call in flushSync, but now every click forces a synchronous, blocking render — the exact cost React's concurrent rendering exists to avoid. And even then, you've only solved this click. Add a search filter that also changes which thumbnails exist, and the browser has no idea which old element corresponds to which new one; it just cross-fades the whole container.

The actual problem: the native API assumes the code calling it fully controls when the DOM change happens. React doesn't — a setState call describes an intent to update, and the actual commit can be deferred, batched, or (with Suspense) held until data arrives. The stable <ViewTransition> component exists to close that gap: it hooks into React's own commit and Suspense machinery instead of assuming a single synchronous mutation.

The mental model: <ViewTransition> doesn't animate every DOM change inside it — it only animates changes that happen as part of a Transition, the same concept useTransition and startTransition already use to mark an update as non-urgent. Wrap the element you want to animate, then make the state change that affects it through startTransition (directly, through a Transition-aware router, or through useDeferredValue). A ViewTransition sitting around an element that changes via a plain, untransitioned setState renders instantly, with no animation, exactly as if the component weren't there.

JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { ViewTransition, startTransition, useState } from "react"; function Gallery() { const [selectedId, setSelectedId] = useState(null); return ( <button onClick={() => { // The state change is wrapped in a Transition — this is what // lets any <ViewTransition> below react to it at all. startTransition(() => setSelectedId(42)); }} > Open photo </button> ); }

Key concept: "Transition" here is not a metaphor for "animation" — it's the specific React primitive that marks an update as interruptible and lower-priority. <ViewTransition> reuses that exact signal to decide when to even ask the browser for a snapshot. No Transition, no snapshot, no animation — by design, not by accident.

Once an update is a Transition, <ViewTransition> classifies what happened to each wrapped element into exactly one of four shapes, and animates accordingly:

PropFires when…
enterThis ViewTransition is the first thing inserted, anywhere in the tree, during this Transition
exitThis ViewTransition is the first thing removed during this Transition
updateThe element stays, but something inside it changed — content, size, or position, often because a sibling resized
shareA named ViewTransition in a removed subtree shares its name with one in an inserted subtree — React treats them as the same visual element and animates between them

Start with the shape that needs the least setup: something appearing or disappearing.

JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { ViewTransition, startTransition, useState } from "react"; function Notice({ message }) { return ( <ViewTransition enter="slide-in" exit="fade-out"> <div className="notice">{message}</div> </ViewTransition> ); } function App() { const [notice, setNotice] = useState(null); return ( <> <button onClick={() => startTransition(() => setNotice("Saved!"))} > Save </button> {notice && <Notice message={notice} />} </> ); }

enter/exit accept "auto" (the browser's default cross-fade), "none", or a class name whose CSS you write yourself, styling the browser's own ::view-transition-new(*)/::view-transition-old(*) pseudo-elements — the same pseudo-elements the native API exposes, just targeted through a name React assigns for you instead of one you manage by hand.

CSS
1
2
3
4
5
6
::view-transition-new(.slide-in) { animation: slide-in 200ms ease-out; } @keyframes slide-in { from { transform: translateY(-12px); opacity: 0; } }

Key concept: you are not writing document.startViewTransition anywhere. React decides when to call the browser API, based on the Transition boundary; you only decide how the animation looks once it does.

An element that stays mounted but changes shape needs update, not enter/exit — this is also what fires on a sibling whose own resize pushes this element to a new position, even if this element's own content didn't change:

JSX
1
2
3
4
5
6
7
8
9
10
function ExpandableCard({ expanded, onToggle, summary, detail }) { return ( <ViewTransition update="auto"> <div className="card" onClick={onToggle}> <p>{summary}</p> {expanded && <p className="detail">{detail}</p>} </div> </ViewTransition> ); }

Clicked through a Transition, the card's height change animates smoothly instead of snapping — and any sibling card pushed down the page animates its own repositioning too, because a position change from a neighbor's resize is exactly what update is defined to cover.

This is the shape the opening problem needed, and it's the one the raw browser API structurally can't do inside a dynamic list: the same visual element exists in two different subtrees — a thumbnail in a grid, a hero image in a detail view — and you want React to treat them as one continuous element across the transition, even though, as far as the reconciler is concerned, one unmounted and a different one mounted.

JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Thumbnail({ photo, onSelect }) { return ( <ViewTransition name={`photo-${photo.id}`}> <img src={photo.thumbUrl} onClick={() => startTransition(() => onSelect(photo.id))} /> </ViewTransition> ); } function DetailView({ photo }) { return ( <ViewTransition name={`photo-${photo.id}`}> <img src={photo.fullUrl} className="detail-image" /> </ViewTransition> ); }

Give the outgoing and incoming elements the same name, and if one is removed while the other is inserted in the same Transition, React pairs them as a shared-element transition — the browser morphs position and size from the small thumbnail to the full image, the way you'd hand-wire view-transition-name on a static page, except this now survives whatever filtering or sorting also happened in that same click. This is precisely the case from the opening bug: the naive flushSync version couldn't tell the browser "this thumbnail is that hero image" once a filter changed which items existed; naming both sides is what makes the identity explicit instead of positional.

This is also why share has to be a React-level concept, not a CSS one: whether the outgoing thumbnail and the incoming hero image are "the same element" is exactly the identity question React Re-render vs Remount: What Actually Triggers Each covers for ordinary reconciliation — share just answers it explicitly with a name, for the one case (two different subtrees) where React's own fiber identity can't do it for you.

Runs right in your browser — poke at it and watch the concept react live.

Every one of the animations above can also be told why it's happening, so the same ViewTransition can look different for "the user navigated forward" versus "the user navigated back" — without threading a prop down to describe it:

JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { addTransitionType, startTransition } from "react"; function goToNext() { startTransition(() => { addTransitionType("nav-forward"); setPage((p) => p + 1); }); } function goToPrevious() { startTransition(() => { addTransitionType("nav-back"); setPage((p) => p - 1); }); }

React forwards whatever string you pass as a browser view-transition type, so your CSS can target it directly — but :active-view-transition-type() only ever matches the document root, so it has to wrap the pseudo-element rule rather than chain after it:

CSS
1
2
3
4
5
:root:active-view-transition-type(nav-back) { &::view-transition-old(.card) { animation: slide-out-right 200ms; } }

Key concept: addTransitionType doesn't change whether something animates (that's still enter/exit/update/share, decided per-element) — it only labels the transition so your CSS can branch on cause. One ViewTransition handles both directions; the label is what tells them apart.

  • A ViewTransition around an element that changes outside any Transition is inert. If you find an animation just isn't firing, check the state update first — a plain onClick={() => setState(x)} is the single most common reason, not a missing prop.
  • share needs the name to be unique in each subtree at the moment of the swap, exactly like the native API's view-transition-name. Two simultaneously-mounted elements with the same name isn't a silent quirk — React detects it and errors, logging both offending elements in development — so a dynamic name derived from a stable ID (photo-${id}), not the array index, is what makes this safe in a re-orderable list.
  • Suspense-blocked content usually delays the "after" snapshot, not the animation itself — React waits for newly-suspended content to be ready before asking the browser to animate, so a slow detail view doesn't produce a transition into a loading spinner; the whole Transition just starts later. The one case that's stronger than a delay: if a Suspense fallback actually appears between a share pair's removal and its matching insertion, the shared-element transition doesn't happen at all for that pair — worth knowing before you assume every Suspense-gated update animates, just late.
  • Multiple Transitions in flight no longer block each other. As of the same 19.3 release, transitions render independently rather than being entangled into a single render, so triggering a second, unrelated Transition while a slow one is still resolving doesn't stall it.
  • Browser support isn't universal. The View Transitions API this all sits on top of is a Chromium-and-Safari-shipped feature with gaps elsewhere; without support, <ViewTransition> should degrade to an instant, unanimated update rather than an error — verify that in your own target browsers rather than assuming it, and never let a missing API break the update itself.
  • This is not a general-purpose animation library. There's no spring physics, no stagger helper, no timeline — you're styling two browser pseudo-elements with CSS. For anything beyond enter/exit/resize/shared-element, a dedicated animation library is still the right tool.

  • Reach for it when an interaction already goes through a Transition (a route change, a filter, a tab switch, an optimistic update) and the result deserves to feel continuous — a list reordering, a card expanding, a thumbnail opening into detail.
  • Name only what needs to persist visually. Give name to the handful of elements that are the actual subject of a shared-element transition; wrapping everything in a named ViewTransition just adds bookkeeping for animations nobody will notice.
  • Don't force an update into a Transition just to get an animation. If the update isn't naturally low-priority or interruptible, wrapping it in startTransition to unlock <ViewTransition> is solving the wrong problem — a plain CSS transition on the element may be simpler and more honest about what's actually happening.
  • Avoid it when the change already crosses a full page navigation between separately loaded documents — that's still the native @view-transition { navigation: auto; } CSS rule's job, not this component's.

Because activation depends on the state change happening inside a Transition (startTransition, useTransition, or a Transition-aware router navigation), not on the presence of the <ViewTransition> wrapper alone. A plain setState renders instantly and skips the browser's view-transition machinery entirely, by design.

You don't call document.startViewTransition yourself — React does that internally when it detects the underlying browser supports it. You do still rely on the same browser feature under the hood, so support follows the native API's own availability.

update is for an element that stays the same element across the Transition but changes in place (content, size, position from a sibling's resize). share is for two different elements — one removed, one inserted — that you're telling React to treat as a single continuous element by giving them matching names.

Yes — that's a core reason it needed to be a React component rather than a manual API call. It coordinates with Suspense boundaries inside the same Transition, waiting for suspended content to resolve before the animation's "after" state is captured, instead of animating into a loading fallback.

No. The Compiler (stable at 1.0) automates memoization of values computed during render; it has no relationship to Transitions, Suspense, or the browser's view-transition lifecycle. Nothing here needs the Compiler, and nothing about the Compiler changes how <ViewTransition> behaves.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

JSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { ViewTransition, startTransition, addTransitionType, } from "react"; // 1. The state change MUST go through a Transition, or nothing animates. startTransition(() => setSelectedId(id)); // 2. Pick the shape: <ViewTransition enter="auto" exit="auto"> {/* mounts / unmounts */} <ViewTransition update="auto"> {/* stays, resizes/moves */} <ViewTransition name={`item-${id}`}> {/* shared element pair */} // 3. Style the browser pseudo-elements React targets for you: // ::view-transition-old(<class-or-name>) // ::view-transition-new(<class-or-name>) // 4. Label WHY, to branch the CSS on cause: startTransition(() => { addTransitionType("nav-back"); setPage(p => p - 1); }); // :root:active-view-transition-type(nav-back) { &::view-transition-old(.card) { … } }
PropUse forFires on
enterNew content appearingFirst mount inside this Transition
exitContent disappearingFirst unmount inside this Transition
updateSame element, different size/content/positionIn-place change or a sibling's resize
share (via matching name)Thumbnail → detail, list item → expanded cardMatched removal + insertion, same Transition

  • <ViewTransition> only reacts to changes made inside a React Transition — a plain setState is invisible to it, on purpose.
  • Every activation is one of four shapes: enter, exit, update, or share — matching names across a removal and an insertion.
  • Suspense-blocked content delays when the animation's "after" state is captured; it doesn't corrupt the animation.
  • addTransitionType labels why an update happened so one component's CSS can branch on cause, separate from whether it animates.
  • It replaces the manual document.startViewTransition + flushSync workaround, not the browser API itself — you're still styling ::view-transition-old/::view-transition-new.

The bug in the opening scene wasn't the animation idea — it was asking a synchronous browser API to describe an asynchronous React update. startTransition is what tells <ViewTransition> a change is happening at all, and name is what tells it which old element the new one is actually replacing, even after a filter reshuffled the whole list around it. Once both are in place, the thumbnail-to-detail morph survives exactly the kind of re-render that broke the hand-wired version — no flushSync, no manual snapshot timing.

If you've been holding off on this kind of animation because the native API only seemed to work on static pages, that's the gap this component closes. What's the first shared-element transition you're going to try it on?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.