2026.10.09 · 10 min · react · nextjs · animation · css
React <ViewTransition>: wrap it, then start a transition.
React 19.3 made <ViewTransition> stable, and Next.js 16.4 ships it. Wrapping an element is only half of it: nothing animates until the update is a Transition. What triggers it, the four kinds of animation React picks, what the browser actually does, and the pitfalls that make it silently do nothing.
Take one component and one state update. Run it twice. The first time, the card snaps in: one frame it isn't there, the next it is. The second time, it fades in. The JSX is identical in both runs, and so is the state. The only difference is one function call around the setState.
That function is startTransition, and the component that turns it into an animation is <ViewTransition>. React shared it as an experimental API in April 2025. On 2026.09.09 it became stable in React 19.3, and on 2026.10.06 Next.js 16.4 started shipping that version of React. So if you are on current Next.js, you already have it.
Watch the 60-second version on Instagram.
This post is the long version: what you wrap, what makes it animate, the four kinds of animation React chooses for you, what the browser is doing underneath, and the handful of mistakes that make it quietly do nothing.
The first place I would use it is this site: a shared element from a card on the works index into the header of its case study.
Why this is worth knowing
Enter and exit animations have always been awkward in React. When a component unmounts, its DOM is gone; there is nothing left to fade out. So we reached for libraries that keep the element around until its exit animation finishes, measure positions before and after a layout change, and coordinate the timing by hand. The "thumbnail grows into the detail page" effect was the hardest of all, because the two elements live in different components, often on different routes.
The browser solved most of that with the View Transition API: you tell it you are about to change the DOM, it takes a picture before and after, and it animates between the pictures. <ViewTransition> is React's declarative way of using it. No animation library, no measuring, a component plus some CSS.
Step one: wrap it
import { ViewTransition } from 'react';
<ViewTransition>
<Card />
</ViewTransition>With no props, that boundary gets the browser's default animation: a cross-fade. React assigns it a unique view-transition-name behind the scenes, and calls document.startViewTransition itself at the right moment. You don't call that API yourself; React owns it.
Here is the part the name hides. If you stop at this step and toggle Card with a plain setShow(true), nothing animates. The card snaps in exactly as it did before you wrapped it.
Step two: start a transition
The React reference is explicit about what activates a <ViewTransition>. There are three triggers:
| Trigger | When it fires |
|---|---|
startTransition(() => …) | A state update you mark as a Transition. |
A <Suspense> reveal | The fallback is replaced by the content that loaded. |
useDeferredValue(…) | The deferred value catches up. |
A plain setState is none of these, so it is a hard cut. That is the difference in figure 1: the left card is setShow(true), the right one is the same call inside startTransition.
import { ViewTransition, startTransition, useState } from 'react';
function Panel() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => startTransition(() => setShow((s) => !s))}>
Toggle
</button>
{show && (
<ViewTransition>
<Card />
</ViewTransition>
)}
</>
);
}The highlighted line is the whole change. I think this is the right design, even if it trips everyone up the first time. A Transition already means "this update is not urgent, it can take a moment". An animation is a way of spending that moment. Urgent updates, like typing in an input, should never wait for a 300 ms fade, and tying animation to Transitions guarantees they won't.
React picks the animation
Once a Transition commits, React looks at what happened to each boundary and picks one of four kinds:
- Enter: the boundary was inserted in this Transition.
- Exit: the boundary was removed.
- Update: React changed DOM inside it (a prop, some text), or the boundary itself moved or resized because an immediate sibling was inserted or removed.
- Share: a boundary with a
namewas removed in one place and another with the samenamewas inserted somewhere else, in the same Transition. The two form a shared element transition: the old one morphs into the new one.
Each kind is a prop. Each prop takes "auto" (the browser's cross-fade), "none", or a CSS class name:
<ViewTransition enter="slide-in" exit="slide-out" update="none">
<Card />
</ViewTransition>React applies that class when the matching animation runs, and you style it with the view transition pseudo-elements, using the class as the selector:
::view-transition-new(.slide-in) {
animation: 300ms ease-out both slide-up;
}
::view-transition-old(.slide-out) {
animation: 150ms ease-in both fade-out;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px); }
}
@keyframes fade-out {
to { opacity: 0; }
}Note the easing: ease-out for what enters, ease-in for what leaves, and the exit shorter than the enter. The same rules I wrote about in the easing post apply here, because this is ordinary CSS animation.
There is also a default prop that covers every kind you don't name. default="none" is the useful one: it switches a boundary off for everything except the kinds you list. Without it, a boundary joins in on every Transition that touches it, which on a busy page means things fading that you never meant to animate.
What the browser is actually doing
The common mental model is that <ViewTransition> is React's animation engine. It isn't. React's job is to decide which elements take part, give them names and classes, and call document.startViewTransition around its commit. The animation itself is the browser's, and it works on pictures, not on your components:
- The browser captures the old state of every named element as a static image.
- React updates the DOM.
- The browser captures the new state. These snapshots are live: they keep rendering what is on the page.
- It builds an overlay above the page: a
::view-transition-groupper name, each holding a::view-transition-oldand a::view-transition-new. By default the old image fades from opacity 1 to 0, the new one from 0 to 1, and the group animates size and position between the two. - When the animations finish, the overlay is removed and you are looking at the real page again.
Knowing this explains most of the behaviour that otherwise looks like bugs:
- A boundary animates as one image. The children inside it don't animate independently; the reference says plainly that this is not a layout animation of every element. If you want two parts to move separately, give them separate boundaries.
- The overlay covers the page. While it is up, it captures pointer events, so a click during a transition is lost. The Next.js guide's fix is one line:
::view-transition { pointer-events: none; }. - Unsupported browsers just don't animate. If the API is missing, the DOM still updates; there is simply no picture to animate. Nothing breaks.
In Next.js: navigations are already transitions
In the App Router, route navigations run inside a Transition. So <ViewTransition> works with no configuration: wrap your page content and moving between pages cross-fades. Next.js 16.4 itself adds no view-transition flag or feature; it ships React 19.3, and that is enough.
Two additions make it useful rather than just pretty. The first is direction. <Link> takes transitionTypes, and a boundary can choose a class per type:
<Link href={`/photos/${id}`} transitionTypes={['nav-forward']}>…</Link>
<ViewTransition
enter={{ 'nav-forward': 'slide-left', 'nav-back': 'slide-right', default: 'none' }}
exit={{ 'nav-forward': 'slide-left', 'nav-back': 'slide-right', default: 'none' }}
default="none"
>
{/* page content */}
</ViewTransition>Outside Next.js, the same thing is addTransitionType('nav-forward') called inside startTransition, also new in React 19.3.
The second is the shared element: put a <ViewTransition> with the same name (say, photo- plus the photo's id) around the thumbnail in the grid and around the hero image on the detail page, and the thumbnail morphs into the hero as you navigate. The Next.js guide notes the morph only forms when the destination renders in the same commit as the navigation, which is the case for prefetched pages. If the destination suspends into a fallback first, there is no pair, and the content plays its enter animation instead.
On a portfolio like this one I would keep it to shared elements. The home page is already a scroll-driven film, and page-level slides on top of that would be motion competing with motion.
Why it silently does nothing
Each of these comes from the React reference or the Next.js guide, and only the duplicate name raises an error. The rest fail silently.
| Symptom | Cause | Fix |
|---|---|---|
| Nothing animates | The update is a plain setState. | Wrap it in startTransition. |
| Enter/exit never fire | A DOM element (a <div>) sits above the <ViewTransition> in the component's output. | Make the boundary the first thing the component renders, before any DOM node. |
| Page enter/exit never fire in Next.js | The wrapper is in layout.tsx. Layouts persist across navigations. | Put it in each page.tsx. |
| The shared morph stopped | You added default="none" to a named pair. | Keep an explicit share alongside it (share="morph" or "auto"). |
| Error about two boundaries with the same name | Two mounted boundaries share a name. | Names must be unique among mounted boundaries; derive them from an id. |
| Children don't move separately | The whole boundary is one snapshot. | Split it into several boundaries. |
| No directional slide on the back button | Browser back carries no transition type. | Expected; let default: 'none' keep it calm. |
| Animation skipped mid-way | A flushSync landed during the transition. | Avoid flushSync inside the update. |
Browser support
Same-document view transitions work in Chrome and Edge 111+, Safari 18+ and Firefox 144+, about 93% of users globally according to Can I use. React's integration leans on two newer parts of the API, transition types and view-transition-class, which the Next.js guide puts at Chromium 125+ and recent Safari and Firefox, with a note that some animations may look different in Safari. Below that, the page works and simply doesn't animate. That is the kind of progressive enhancement I like: the fallback is the app you already had.
Motion is still your responsibility
React does not respect prefers-reduced-motion for you; the reference says so directly. The view transition pseudo-elements are ordinary CSS, so the fix is a media query. The blunt version, from the Next.js guide, turns every view transition into an instant swap:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}A gentler version keeps cross-fades and removes only movement, since sliding across the viewport is what most commonly causes trouble. On this site, every figure is drawn in its final state, so with motion off you still see the outcome; the same thinking applies here.
The short version
import { ViewTransition } from 'react', stable since React 19.3, shipped in Next.js 16.4.- Wrapping is not enough. Only
startTransition, a<Suspense>reveal oruseDeferredValueanimate. A plainsetStateis a hard cut. - React picks enter, exit, update or share. Pass a class per kind and style it with
::view-transition-old/new/group(.class). - The browser does the animating, on snapshots. Each boundary moves as one image.
- In Next.js, navigations are already Transitions: wrap the page (not the layout) and it animates.
- Honour
prefers-reduced-motionyourself.
Wrap it, then start a transition.
Sources: React 19.3 announcement · React reference, <ViewTransition> · Next.js 16.4 · Next.js guide, Designing view transitions · MDN, Using the View Transition API · Can I use, View Transitions API (single-document)