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.

- 1React Re-render vs Remount: What Actually Triggers Each13 min
- 2React Compiler 1.0: What useMemo You Can Delete13 min
- 3React Form Actions: useActionState & useFormStatus Guide13 min
- 4React Derived State: Why That useState Is Probably a Bug14 min
- 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
setStateupdate never triggers aViewTransitionanimation, and what has to wrap it instead - Use the four activation props —
enter,exit,update, andshare— 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
addTransitionTypeso 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 problem: an animation that only survives a full page load
- The mental model: a Transition decides whether anything animates at all
- Stage 1: enter and exit — the smallest activation
- Stage 2: update — animating a resize in place
- Stage 3: share — a shared-element transition across a re-render
- Stage 4: naming the transition with addTransitionType
- Edge cases and gotchas
- Best practices: when to reach for ViewTransition
- FAQ
- Cheat sheet
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:
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.
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:
| Prop | Fires when… |
|---|---|
enter | This ViewTransition is the first thing inserted, anywhere in the tree, during this Transition |
exit | This ViewTransition is the first thing removed during this Transition |
update | The element stays, but something inside it changed — content, size, or position, often because a sibling resized |
share | A 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.
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.
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:
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.
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:
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:
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
ViewTransitionaround an element that changes outside any Transition is inert. If you find an animation just isn't firing, check the state update first — a plainonClick={() => setState(x)}is the single most common reason, not a missing prop. shareneeds the name to be unique in each subtree at the moment of the swap, exactly like the native API'sview-transition-name. Two simultaneously-mounted elements with the samenameisn'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
sharepair'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
nameto the handful of elements that are the actual subject of a shared-element transition; wrapping everything in a namedViewTransitionjust 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
startTransitionto 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.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
| Prop | Use for | Fires on |
|---|---|---|
enter | New content appearing | First mount inside this Transition |
exit | Content disappearing | First unmount inside this Transition |
update | Same element, different size/content/position | In-place change or a sibling's resize |
share (via matching name) | Thumbnail → detail, list item → expanded card | Matched removal + insertion, same Transition |
<ViewTransition>only reacts to changes made inside a React Transition — a plainsetStateis invisible to it, on purpose.- Every activation is one of four shapes:
enter,exit,update, orshare— matchingnames across a removal and an insertion. - Suspense-blocked content delays when the animation's "after" state is captured; it doesn't corrupt the animation.
addTransitionTypelabels why an update happened so one component's CSS can branch on cause, separate from whether it animates.- It replaces the manual
document.startViewTransition+flushSyncworkaround, 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?
- React Derived State: Why That useState Is Probably a Bug
- React Re-render vs Remount: What Actually Triggers Each
- React Compiler 1.0: What useMemo You Can Delete
🚀 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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
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.