React 19.3 ViewTransition: Why Nothing Animates
I bumped React to 19.3 on a Tuesday afternoon expecting it to be a chore. No breaking changes, ViewTransition and Fragment refs out of canary, five minutes of work. Then I spent the afternoon learning that a plain setState will never animate anything.
The component reads well and it is easy to use wrong, which is the worst combination for a UI API. Everything below is what I hit, in the order I hit it.
The gate is startTransition
I wrapped a panel in <ViewTransition>, wired up a button, clicked it, and got a hard cut. No warning, no console message, no difference from the code I had before.
// nothing happens
<button onClick={() => setOpen(!open)}>Toggle</button>
// this one animates
<button onClick={() => startTransition(() => setOpen(!open))}>Toggle</button>
A boundary only activates for updates React already treats as non-urgent. Three things qualify: a state update inside startTransition, a Suspense boundary revealing its content, and a value coming from useDeferredValue. A click handler setting state is urgent, commits right away, and skips the animation on purpose.
I was annoyed for ten minutes, then decided the docs are right. Typing in a search field should never cross-fade. The updates where motion earns its place were deferrable anyway, and tying the two together kills a category of bug where an animation fights the input it was meant to polish.
The boundary has to be the thing being inserted
React looks at how the tree changed and picks one of four animations: enter when the boundary is added, exit when it is removed, update when its children change, share when a named boundary disappears in one subtree while the same name shows up in another.
I wanted a slide and got a cross-fade, because the boundary never unmounted. Only its contents were changing. A key fixed it:
<ViewTransition key={tab}>
{tab === 'inbox' ? <Inbox /> : <Sent />}
</ViewTransition>
Changing the key unmounts one boundary and mounts another, so React sees an exit and an enter. Without it the boundary stays mounted and you get an update, which is a cross-fade in place. Both are legitimate, they just mean different things.
Then the same class of bug bit me one level deeper:
// cross-fade, no slide
<div className="toast-wrap">
<ViewTransition>Saved</ViewTransition>
</div>
// slides in
<ViewTransition>
<div className="toast-wrap">Saved</div>
</ViewTransition>
Enter and exit fire when the boundary is the first thing inserted in that transition. Nest it inside a wrapper and the wrapper becomes the inserted node, so the boundary inside has nothing to enter. Same markup, same CSS, different result.
Stop naming things
This is the real difference from the raw browser API, where I had to set view-transition-name on both sides myself and clear it afterwards. In 19.3 you pass the name prop only for shared elements, the thumbnail-that-becomes-a-hero case, and React generates a unique name for every other boundary.
That detail matters more than it sounds. The name has to be unique across the entire app, so a name="card" copied into three list components is a bug waiting to surface, and a duplicate drops the whole transition rather than just the second card.
One migration note for anyone who shipped the old workaround: React calls startViewTransition itself and will interrupt anything else on the page trying to do the same. The flushSync wrapper I had been dragging around now fights React for the same transition, so it went out with the upgrade.
Direction, and the object form of the props
A carousel is the obvious next problem. Forward and back both land on the same state value, so React has no way to know which way you moved. addTransitionType is that channel:
function next() {
startTransition(() => {
addTransitionType('forward')
setSlide(n => n + 1)
})
}
Then enter, exit, update, share and default each take a class name string, "auto" for the browser default, "none" to switch an animation off, or an object keyed by type with a default fallback. That object form is the cleanest part of the release in practice.
React also puts each type on the element as a browser view transition type, so :active-view-transition-type(forward) works in plain CSS if you prefer the decision to live there.
What looks broken mid-flight
A boundary animates a captured image of the region, not the live nodes. That is what makes the animation cheap, and it is also why a spinner inside a transitioning region looks frozen during the fade. It is a screenshot. Anything with parts that move on their own needs a nested boundary of its own.
Overlapping updates batch as well. Start A to B, then land updates toward C and D while the first is still running, and the next animation goes from B to D instead of replaying the queue. New fonts hold a transition up for up to 500ms, and an image inside a boundary waits for the image. That one fixed a hero image that used to pop in half-loaded, so no complaints from me.
Before you ship
React does not turn animations off for people who asked for less motion. The docs say to add the media query yourself, and I would treat that as a requirement rather than a suggestion. It is also DOM only right now, so a shared component library should not assume the behavior survives on React Native.
I would skip it for UI that is cached and appears instantly. Animating something that was already on screen is a delay dressed up as polish. What I kept is narrow: a panel that slides in from the side, a list row that expands into a detail view, and a settings tab that stopped jumping. A chunk of animation code gone, none of it doing anything the browser could not do.
The two snippets I keep retyping, the key trick and the object form of the enter and exit props, live in Snippet Ark next to the CSS I wrote for the old API, since the two are easy to confuse a month apart.