Contents
In brief
A photo grid and a detail view are two pages of the same album on the table. Tap a small print and it should grow into the large one without the book slamming shut.
React 19.3 ships <ViewTransition> as stable for that job. The component waits until React has actually committed the new tree, then asks the browser for the "after" snapshot.
The same name on the thumbnail and the large image says which old element the new one replaces, even when that click also changes a filter and the selected id.
What happened
The write-up is checked against React 19.3.0. The GitHub changelog dated 9 September 2026 marks <ViewTransition> and addTransitionType stable, not experimental. It assumes function components and hooks from the React 19 era.
The browser's View Transitions API is simple for one shape of change. document.startViewTransition() wraps a DOM update you finish yourself, synchronously, inside the callback. Old and new elements share a view-transition-name, and the browser tweens one rectangle into the other.
A thumbnail click in React is almost never that update. Something like setSelectedId does not touch the DOM. It schedules a re-render. By the time the filtered list, the search box, and the selected id agree, the browser has often already snapshotted two identical frames. The motion glitches, sticks to the wrong element, or never starts, and DevTools does not say why.
flushSync can force the render to finish inside the callback. The cost is a synchronous, blocking pass — the cost concurrent rendering exists to avoid. A filter that changes which thumbnails exist still leaves the browser with no identity: it cross-fades the whole container.
<ViewTransition> closes that gap. It hooks into React's own commit and into Suspense, instead of assuming one synchronous mutation.
The article is strict about the word "transition". It is not a synonym for animation. It is the same mark startTransition and useTransition already use: the update is interruptible and not the most urgent work. A <ViewTransition> only reacts to that mark. A plain setState paints instantly, as if the wrapper were absent. The mark can come from startTransition directly, from useDeferredValue, or from a router that flags navigation this way.
Once the update is marked, each wrapped node falls into exactly one of four shapes.
| Shape | When it fires |
|---|---|
enter |
this node is the first insertion in the tree during the transition |
exit |
this node is the first removal |
update |
the node stays, but content, size, or position changed — often because a sibling resized |
share |
a named node leaves one subtree and another with the same name is inserted |
That last row is the album. To the reconciler the small print and the large print are different nodes. To the eye they are one photograph.
Why it matters
Opening a photo is not a full document swap. The same click may close a search box, change the list page, and wait on data that has not arrived. The native call assumes the callback both owns the DOM change and finishes it before the "after" snapshot. React records an intent. The commit can be deferred, batched, or held until Suspense has data.
Until something sits on that commit, a shared element only survives a static page. The moment the list is filtered or reordered, the browser no longer knows which thumbnail is the hero image, and the morph falls apart.
Matching names make the identity explicit. Ordinary reconciliation can tell a re-render from an unmount. It cannot treat two different subtrees as one continuous element. share answers "is this the same picture?" with a name, in the case where the node's own identity cannot.
addTransitionType does not decide whether anything moves. enter, exit, update, and a matching name still do that. The string only labels why the update happened, so CSS can tell a forward step from a back step without an extra prop drilled through the tree.
As of the same 19.3 release, in-flight transitions no longer block one another. They render independently instead of being entangled into a single render, so a second, unrelated transition does not have to wait on a slow one.
The React Compiler, stable at 1.0, is unrelated. It memoizes values computed during render. It does not change transitions, Suspense, or the browser's view-transition lifecycle.
In practice
Reach for the component when the interaction already goes through a transition: a route change, a filter, a tab switch, an optimistic update. Use it when the result should feel continuous — a list reordering, a card expanding, a thumbnail opening into detail.
Name only what the eye must recognize. A named wrapper on every node is bookkeeping for motion nobody will notice.
Do not wrap an update in startTransition only to unlock the animation. If the change is not naturally low priority or interruptible, a plain CSS transition on the element is the more honest tool.
A navigation between separately loaded documents is still the CSS rule @view-transition { navigation: auto; }, not this component.
The article's thumbnail and detail view share a name like this:
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>
);
}
If one node with that name is removed and another with the same name is inserted in the same transition, the browser tweens position and size from the small image to the large one. Filtering or sorting in that same click does not break the pair: identity is the name, not the slot in the array. The album stays open. The small print grows because the page has already been rearranged.
Appear and disappear need the least setup. enter and exit accept auto (the browser's default cross-fade), none, or a class name you style yourself. The rules target ::view-transition-new and ::view-transition-old. You never call document.startViewTransition. React decides when to call the browser.
A node that stays mounted but changes height uses update, not enter and exit. A sibling pushed down by a neighbor's resize also gets update: a position change from someone else's resize is part of that definition.
The string passed to addTransitionType is forwarded as a browser view-transition type. :active-view-transition-type() only matches the document root, so the pseudo-element rule has to sit inside it:
:root:active-view-transition-type(nav-back) {
&::view-transition-old(.card) {
animation: slide-out-right 200ms;
}
}
Edges the source asks you to keep in mind:
- A wrapper around a change that is not inside a transition is inert. The usual cause is a plain
onClick={() => setState(x)}, not a missing prop. - The name must be unique in each subtree at the moment of the swap, same as the browser's
view-transition-name. Two mounted elements with one name are an error: in development React logs both. Derive the name from a stable photo id, not from the array index. - Content blocked on
Suspenseusually delays the "after" snapshot rather than corrupting the motion. React waits so you do not animate into a loading spinner. If a Suspense fallback actually appears between a shared pair's removal and its matching insertion, that pair does not get a shared-element transition at all. - Without support for the underlying API, the component should update instantly and not throw. The article places support in Chrome and Safari, with gaps elsewhere. Check your target browsers, and do not let a missing API break the update itself.
- This is not a general animation library. There is no spring physics, no stagger helper, and no timeline. You style two CSS pseudo-elements. Anything beyond enter, exit, resize, and a shared element still belongs in a dedicated library.
Takeaway
The bug in the grid was not the idea of a smooth frame. A synchronous browser call was asked to describe an update React had not committed yet.
startTransition is what tells <ViewTransition> that a change is worth animating. A shared name is what tells it which thumbnail the detail image replaces, even after a filter reshuffles the list.
The album stays open: the small print grows into the large one on the page that is already there, without flushSync and without timing the snapshot by hand.



Comments