React View Transitions in Next.js 16.4: ViewTransition, addTransitionType and Tested Examples
React View Transitions are stable in React 19.3 and work in Next.js 16.4 with no config. Shared-element morphs, Suspense reveals, directional slides and addTransitionType, tested in Chrome, plus the back-button gap.

On this page
React View Transitions animate UI changes with the browser's View Transition API, declaratively. You wrap elements in <ViewTransition>, and React animates them when they enter, exit, update or move between pages. They became stable in React 19.3 on September 9, 2026, and Next.js 16.4 ships React 19.3, so they work in the App Router with no configuration.
I built a small Next.js 16.4.2 app with the four main patterns: a shared-element morph, a Suspense reveal, directional slides and a tab animation with addTransitionType. Then I recorded every transition in headless Chrome 155 to see what actually fires. Most of it behaves as documented. Browser back and forward do not, so that gets its own section below.
What are React View Transitions?#
React View Transitions are the <ViewTransition> component and the addTransitionType function, stable since React 19.3. They hand animations to the browser's document.startViewTransition() from the View Transition API: the browser snapshots the old and new UI and animates between them, while React decides which elements take part and which CSS class each animation uses.
React picks the animation from how the tree changed. From the React 19.3 announcement:
Each prop takes "auto" (the browser's crossfade), "none", a CSS class name you style, or an object that maps transition types to classes.

What triggers a view transition in React?#
Only Transitions do. React animates updates inside startTransition, a <Suspense> boundary revealing its content, and updates from useDeferredValue. A plain setState does not animate, because React treats it as urgent. In Next.js App Router, every <Link> and router.push() navigation is a Transition, so <ViewTransition> animates route changes automatically.
This is the mistake behind most "my view transition doesn't run" reports. If you call setIndex(2) directly in a click handler, nothing animates. Wrap it in startTransition(() => setIndex(2)) and it does.
Do View Transitions need a flag in Next.js 16.4?#
No. The Next.js view transitions guide says they "work in the App Router with no configuration." The experimental.viewTransition option that older versions needed is gone: I searched Next.js 16.4.2's config schema and it has no viewTransition key. You need React 19.3, which next@16.4 installs.
Requirements and support on October 17, 2026:
Next.js notes that "some animations may behave differently in Safari." Test there before you ship anything elaborate.
How do I add view transitions to a Next.js app?#
Import ViewTransition from react, wrap the element you want to animate, and add CSS for the class names you pass. For route animations, give matching elements the same name on both pages, tag links with transitionTypes, and wrap each page.tsx (not the layout) in a <ViewTransition> that maps those types to animations.

My demo is a grid of four cards. Each card opens a detail page with a large hero, details that stream in from the server, and a tabs page. It runs Next.js 16.4.2, React 19.3.1 and cacheComponents: true (the default for new 16.4 apps), so each detail page is a partial prerender: a static shell plus a dynamic hole. The patterns follow the official Next.js view transitions demo, trimmed down so each one is easy to see.
Step 1: Morph a shared element between pages#
Give the thumbnail and the hero the same name. React pairs them across the navigation and the browser animates size and position:
// app/page.tsx
import { ViewTransition } from "react";
import Link from "next/link";
{items.map((item) => (
<Link key={item.id} href={`/item/${item.id}`} transitionTypes={["nav-forward"]}>
<ViewTransition name={`card-${item.id}`} share="morph" default="none">
<div className="card" style={{ background: item.color }}>{item.title}</div>
</ViewTransition>
</Link>
))}// app/item/[id]/page.tsx
<ViewTransition name={`card-${item.id}`} share="morph" default="none">
<div className="hero" style={{ background: item.color }}>{item.title}</div>
</ViewTransition>default="none" stops the named element from crossfading during unrelated transitions. Next.js warns that with default="none" you must keep the explicit share, or "the pair silently stops morphing." Names must be unique on a page, so build them from an ID.
The morph needs both elements in the same commit. Next.js says this happens with prefetched pages; if the destination suspends first, "no pair forms." In my app the hero sits in the static shell, which <Link> prefetches, so it morphed on every click.
Step 2: Animate a Suspense reveal#
Wrap the fallback in an exit animation and the content in an enter animation:
// app/item/[id]/page.tsx
<Suspense
fallback={
<ViewTransition exit="slide-down" default="none">
<div className="skeleton">Loading details…</div>
</ViewTransition>
}
>
<ViewTransition enter="slide-up" default="none">
<Details id={item.id} />
</ViewTransition>
</Suspense>Details calls await connection(), so it renders at request time. When it streamed in, React started a second, separate view transition about 800 ms after the navigation one, which matches the delay I put in the data function.
Step 3: Slide forward and back#
Tag links with transitionTypes, then map the types to classes in a wrapper you put in every page:
// components/directional.tsx
import { ViewTransition } from "react";
export function Directional({ children }: { children: React.ReactNode }) {
return (
<ViewTransition
enter={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
exit={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}
default="none"
>
{children}
</ViewTransition>
);
}<Link href="/" transitionTypes={["nav-back"]}>← All cards</Link>Use it in each page.tsx. Layouts persist across navigations, so enter and exit never fire on a wrapper placed there. useRouter().push() and replace() also accept transitionTypes.
Step 4: Pick the animation with addTransitionType#
Outside navigation, call addTransitionType inside startTransition to tell React why the update happened. The same setIndex slides left or right depending on direction:
"use client";
import { ViewTransition, addTransitionType, startTransition, useState } from "react";
export function Tabs() {
const [index, setIndex] = useState(0);
function go(next: number) {
startTransition(() => {
addTransitionType(next > index ? "tab-next" : "tab-prev");
setIndex(next);
});
}
return (
<>
{tabs.map((t, i) => <button key={t} onClick={() => go(i)}>{t}</button>)}
<ViewTransition
key={index}
enter={{ "tab-next": "from-right", "tab-prev": "from-left", default: "none" }}
exit={{ "tab-next": "to-left", "tab-prev": "to-right", default: "none" }}
default="none"
>
<div className="panel">{tabs[index]} panel</div>
</ViewTransition>
</>
);
}key={index} makes React treat each panel as a new element, so it runs exit on the old one and enter on the new one. React also adds each type to the browser's view transition, so CSS can match it with :active-view-transition-type(tab-next).
The CSS#
The class names become selectors on the browser's view transition pseudo-elements:
::view-transition-group(.morph) { animation-duration: 400ms; }
::view-transition-old(.nav-forward) { --slide-offset: -60px; animation: 150ms ease-in both fade reverse, 400ms ease-in-out both slide reverse; }
::view-transition-new(.nav-forward) { --slide-offset: 60px; animation: 210ms ease-out 150ms both fade, 400ms ease-in-out both slide; }
::view-transition-old(.nav-back) { --slide-offset: 60px; animation: 150ms ease-in both fade reverse, 400ms ease-in-out both slide reverse; }
::view-transition-new(.nav-back) { --slide-offset: -60px; animation: 210ms ease-out 150ms both fade, 400ms ease-in-out both slide; }
/* Keep the header still and clicks working */
.site-header { view-transition-name: site-header; }
::view-transition-group(site-header) { animation: none; z-index: 100; }
::view-transition-old(site-header) { display: none; }
::view-transition-new(site-header) { animation: none; }
::view-transition { pointer-events: none; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes slide { from { translate: var(--slide-offset); } to { translate: 0; } }
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*), ::view-transition-new(*), ::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}The pointer-events: none rule matters: while a transition runs, the ::view-transition overlay otherwise swallows clicks.
What did my test show?#
Every in-app pattern worked as documented. A forward click produced one transition typed nav-forward with the card-2 pair morphing, then a second untyped transition when the streamed details arrived. Tabs got tab-next and tab-prev. Reduced motion kept the transitions but finished them in under 50 ms. Browser back and forward produced no view transition.
I wrapped document.startViewTransition in a Puppeteer script and logged each call's types, the pseudo-elements it animated, and how long it took to finish:
Both navigation transitions included ::view-transition-old(card-2) and ::view-transition-new(card-2), which is the shared pair.
Do view transitions run on the browser back button?#
Not in my test. Using the browser's back or forward button in Next.js 16.4.2 started no view transition at all, even for the named morph. It was a client-side navigation (same document, URL and grid restored), and zero calls reached startViewTransition. Use in-app back links with transitionTypes={["nav-back"]} for animated returns.
The Next.js guide says browser back navigations "do not carry a transition type, so the directional slide does not play," and that "the shared element morph from Step 1 still applies." In headless Chrome 155 against a production build (next start), I could not reproduce the morph on back or forward. Treat back-button animation as unreliable until you have tested it in your own app and browsers.
Common React View Transition mistakes#
The usual problems are updates that are not Transitions, wrappers placed in layouts, duplicate names on one page, a destination that suspends before the morph can pair, and expecting the back button to animate. Each fails silently: the UI changes instantly with no error.
View Transitions vs Framer Motion (Motion)#
Use View Transitions for route changes, shared elements across pages and loading reveals. The browser animates snapshots, so you add no animation library to your bundle. Keep Motion (formerly Framer Motion) for gestures, springs, drag, scroll-linked effects and animations that must respond while the user interacts.
For the agent route, Next.js links a vercel-react-view-transitions skill you can install with npx skills add vercel-labs/agent-skills --skill vercel-react-view-transitions. My Agent Skills guide explains what that command installs. For the partial prerendering setup used in this demo, see my Next.js use cache guide.
Key takeaways#
- React View Transitions (ViewTransition and addTransitionType) are stable in React 19.3, released September 9, 2026, and work in Next.js 16.4 with no config flag.
- Only Transitions animate: startTransition, Suspense reveals, useDeferredValue and Next.js navigations; a plain setState does not.
- Give two elements the same name to morph them across routes, and keep the destination in a prefetched or static shell so they pair.
- Tag links with transitionTypes and put the enter/exit wrapper in each page.tsx, not the layout.
- In my test, browser back and forward buttons started no view transition at all, so use in-app back links for animated returns.
- Add pointer-events: none on ::view-transition and a prefers-reduced-motion rule to every app.
- Use View Transitions for page-level motion and keep Motion for gestures, springs and interactive animation.
Questions
Share


