React ViewTransition in Next.js: Page Transitions, No Library
Use React 19.3's stable ViewTransition in Next.js 16.3 to morph product images, slide between pages and animate loading states, with no animation library.
CarnielOn this page14 sections
Open any online shop built with the Next.js App Router and click a product. The grid disappears, the product page appears, and nothing on screen tells you those two images were the same sneaker. It's a hard cut. For years the fix was an animation library, a provider around the app, and a fair amount of code to track which element went where.
React 19.3 changed that. It shipped on September 9, 2026, and made the <ViewTransition> component stable after more than a year in experimental builds (React 19.3 release post). It hands the animation to the browser's View Transition API and lets React decide when to run it. Next.js already treats every route change as a Transition, so the two fit together with very little code.
In this tutorial I'll build three effects for a small shop. A product image morphs from the grid into the product page. Pages slide in the direction you're moving. And a loading state stops flickering. Then I'll cover the rules that decide whether any of it animates at all, because when it fails, it fails silently.
Key takeaways
<ViewTransition>is stable in React 19.3, and the Next.js 16.3 guide says view transitions work in the App Router with no config flag.Give two elements the same
nameon different pages and React morphs one into the other during navigation.Use the
transitionTypesprop on<Link>to tell React which way the user is moving, then map each type to a CSS animation.Only updates inside a Transition, a Suspense reveal or
useDeferredValueanimate. A plainsetStatenever does.Browsers without support simply skip the animation, so this is safe progressive enhancement.
What changed in React 19.3 and Next.js 16.3
Two things landed close together. React 19.3 made <ViewTransition> and addTransitionType stable, so you import them from react without an unstable_ prefix. And the current Next.js guide, written for 16.3, says view transitions work in the App Router with no configuration. The App Router ships with React's canary build, which already includes the component (Next.js view transitions guide).
That second point matters because most tutorials you'll find still tell you to add experimental.viewTransition: true to next.config.js. That flag's reference page still exists and is still marked experimental (viewTransition config reference), but the 16.3 guide walks through every pattern without it. If you're on an older 16.x release and nothing animates, try the flag. On 16.3, start without it.
The only import you need:
import { ViewTransition } from 'react'How ViewTransition decides what to animate
You wrap part of your UI in <ViewTransition>, and React picks one of four animations based on how that part of the tree changed (ViewTransition reference):
enter runs when the boundary is added.
exit runs when it is removed.
update runs when its children change content, size or position.
share runs when a boundary with a
nameis removed in one place and a boundary with the same name is added somewhere else.
The trigger is the part people miss. React only animates updates that are marked as Transitions: state set inside startTransition, a Suspense boundary revealing its content, or a value from useDeferredValue. Urgent updates, like a plain setState from a click, are meant to show immediately, so they never animate. In Next.js, route navigations are already Transitions, which is why a <Link> click works with no extra code.
Under the hood, React calls the browser's startViewTransition for you and sets view-transition-name on the right DOM nodes only while the animation runs. The React docs say you should never call startViewTransition yourself, because React will interrupt any other view transition on the page. By default you get the browser's cross-fade. You customize it by passing a class name to enter, exit, update, share or default, then styling it with the ::view-transition-old(.your-class) and ::view-transition-new(.your-class) selectors.
What we're building
A two-page shop: /shop shows a grid of products, and /shop/[slug] shows one product with a large image and a reviews section that loads separately. You need Next.js 16.3 with the App Router. The CSS goes in app/globals.css.
I'll assume a Product type with id, slug, name and image, and a getProduct(slug) data function. If you followed my guide to caching with use cache, that function is already cached, and that turns out to matter for step 1.
Step 1: Morph the product image between pages
This is the effect that makes an app feel physical. Wrap the grid thumbnail and the product page hero in <ViewTransition> with the same name. When you navigate, React finds the pair and animates the image from its old size and position to its new one.
// app/shop/product-grid.tsx
import Image from 'next/image'
import Link from 'next/link'
import { ViewTransition } from 'react'
import type { Product } from '@/lib/products'
export function ProductGrid({ products }: { products: Product[] }) {
return (
<ul className="grid grid-cols-2 gap-4 md:grid-cols-3">
{products.map((product) => (
<li key={product.id}>
<Link href={`/shop/${product.slug}`} transitionTypes={['nav-forward']}>
<ViewTransition
name={`product-${product.id}`}
share="morph"
default="none"
>
<Image src={product.image} alt={product.name} width={400} height={400} />
</ViewTransition>
<p>{product.name}</p>
</Link>
</li>
))}
</ul>
)
}// app/shop/[slug]/product-hero.tsx
import Image from 'next/image'
import { ViewTransition } from 'react'
import type { Product } from '@/lib/products'
export function ProductHero({ product }: { product: Product }) {
return (
<ViewTransition name={`product-${product.id}`} share="morph" default="none">
<div className="relative aspect-square">
<Image src={product.image} alt={product.name} fill priority />
</div>
</ViewTransition>
)
}Three details in that code are doing real work.
The name includes the product ID. Only one boundary with a given name can be mounted at a time. If every card used name="product", React would log an error in development and the transition would fail.
default="none" stops unrelated animations. Without it, every named image on the page cross-fades whenever any Transition runs. The Next.js guide adds a warning worth repeating: once you set default="none" on a named pair, you must also set share, or the morph silently stops.
share="morph" gives you a class to style. The morph works without CSS. I slow it down slightly and use an easing curve that settles gently:
/* app/globals.css */
::view-transition-group(.morph) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}One condition decides whether you see this at all. According to the Next.js guide, the morph only plays when the product page renders in the same commit as the navigation. That happens when the page was prefetched and its data is cached. If the product page shows a Suspense fallback first, no pair forms and the image just fades in as a normal enter. This is exactly what 'use cache' and Partial Prefetching solve, and I explained both in my post on instant navigations in Next.js 16.3.
Step 2: Slide pages in the direction you're moving
A morph shows continuity. Direction shows where you are. Going deeper into the shop should feel like moving forward, and coming back should feel like moving back. React can't guess which is which, so you tell it with transition types.
In Next.js, <Link> accepts a transitionTypes prop. The grid links above already pass ['nav-forward']. The back link on the product page passes ['nav-back']:
<Link href="/shop" transitionTypes={['nav-back']}>
Back to shop
</Link>Then wrap each page in a component that maps those types to CSS classes:
// components/slide-frame.tsx
import { ViewTransition } from 'react'
const slides = {
'nav-forward': 'slide-forward',
'nav-back': 'slide-back',
default: 'none',
}
export function SlideFrame({ children }: { children: React.ReactNode }) {
return (
<ViewTransition enter={slides} exit={slides} default="none">
{children}
</ViewTransition>
)
}// app/shop/[slug]/page.tsx
import { SlideFrame } from '@/components/slide-frame'
import { ProductHero } from './product-hero'
import { getProduct } from '@/lib/products'
export default async function ProductPage({ params }: PageProps<'/shop/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return (
<SlideFrame>
<main>
<ProductHero product={product} />
<h1>{product.name}</h1>
</main>
</SlideFrame>
)
}Put SlideFrame in every page.tsx that should slide, not in layout.tsx. Layouts stay mounted across navigations, so an enter or exit never fires there. And keep SlideFrame as the first thing the page returns. React only runs enter and exit on a <ViewTransition> that comes before any DOM node, so wrapping it in a <div> quietly turns it off.
The CSS moves old content out one way and new content in from the other side. The old page leaves quickly so it doesn't compete with the new one:
/* app/globals.css */
::view-transition-old(.slide-forward) {
animation: 160ms ease-in both vt-fade-out, 320ms ease-in-out both vt-to-left;
}
::view-transition-new(.slide-forward) {
animation: 220ms ease-out 160ms both vt-fade-in, 320ms ease-in-out both vt-from-right;
}
::view-transition-old(.slide-back) {
animation: 160ms ease-in both vt-fade-out, 320ms ease-in-out both vt-to-right;
}
::view-transition-new(.slide-back) {
animation: 220ms ease-out 160ms both vt-fade-in, 320ms ease-in-out both vt-from-left;
}
@keyframes vt-fade-out { to { opacity: 0; } }
@keyframes vt-fade-in { from { opacity: 0; } }
@keyframes vt-to-left { to { translate: -48px 0; } }
@keyframes vt-to-right { to { translate: 48px 0; } }
@keyframes vt-from-right { from { translate: 48px 0; } }
@keyframes vt-from-left { from { translate: -48px 0; } }If your site header slides along with the page, it feels like the whole screen moved. Give the header its own view-transition-name and turn off its animation so it stays still while the content moves:
header.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; }One limit to know: the browser's back button and swipe-back gestures don't carry a transition type, so your directional slide won't play for them. The Next.js guide notes that a named morph can still run in that case.
Step 3: Animate loading states without making them feel slow
The product page loads its reviews separately, behind a Suspense boundary with a skeleton. It's tempting to animate everything here. Don't. An animated skeleton makes the user wait longer to see that their click worked.
The React team gives a simple rule in the 19.3 post. Fallbacks should appear immediately with no animation. The change from fallback to real content should animate. And anything that doesn't need to load should appear instantly (React 19.3 release post). You get exactly that by turning everything off except update:
// app/shop/[slug]/page.tsx (inside <main>)
import { Suspense, ViewTransition } from 'react'
<ViewTransition update="auto" default="none">
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
</ViewTransition>On a first visit, the skeleton shows the moment the page opens, then fades into the reviews when they arrive. On a revisit with cached data, the reviews render straight away and nothing moves. That's the behavior you want: animation only where something actually changed.
There's a bonus. An image or font wrapped in <ViewTransition> makes the nearest Suspense boundary wait for it to load. A product photo no longer pops in half a second after the text around it. The React docs say view transitions wait up to 500ms for new fonts for the same reason.
Why your ViewTransition doesn't animate
The React and Next.js docs warn about six setups that stop an animation. None of them break your page. The animation just doesn't run, which makes them easy to miss.
A wrapper element comes first. Enter and exit only fire when
<ViewTransition>comes before any DOM node in the component.The update isn't a Transition. Wrap custom state changes in
startTransition. Route navigations are covered already.Two elements share a name. Add the item's ID to the name. React logs an error in development when it sees a duplicate.
The destination suspends before it renders. The shared morph needs the new page in the same commit. Prefetch it and cache its data.
The slide wrapper is in a layout. Move it into each page.
The user pressed the back button. It carries no transition type, so only type-based animations are skipped.
Accessibility and browser support
React doesn't turn animations off for users who ask for less motion, and its docs recommend you always handle prefers-reduced-motion yourself. Sliding content is the most likely to cause discomfort, so I remove all view transition animation for those users:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* Let clicks reach the page while a transition runs */
::view-transition {
pointer-events: none;
}The second rule comes from the Next.js guide. During a transition, an overlay sits over the page and swallows clicks. Setting pointer-events: none on it lets fast clicks go through.
Browser support is in good shape. Same-document view transitions became Baseline on October 14, 2025, when Firefox 144 shipped them after Chrome and Safari (web.dev). Firefox's first version didn't include view transition types, which the directional slides rely on. Those arrived in Firefox 147 in January 2026 (web.dev, January 2026). In any browser without support, navigation works normally and simply doesn't animate, so you don't need a fallback.
Should you remove your animation library?
For route transitions, shared elements and loading states, I think yes. You drop a dependency, you drop the provider around your app, and the browser does the animation work instead of JavaScript.
But know what you're trading. A view transition animates a snapshot of the old and new UI, not each element inside it. The React docs point out that this is smoother for large changes but loses continuity for things that should move on their own, so you may need more boundaries than you expect. Gestures, springs you can interrupt mid-drag, and scroll-linked effects are still better served by a dedicated library. Use <ViewTransition> for moving between states, and keep a library only for motion the user controls directly.
Frequently asked questions
Do I need experimental.viewTransition in Next.js 16.3?
The Next.js 16.3 view transitions guide says the App Router supports view transitions with no configuration, because it ships React's canary build, which includes the component. The old flag's reference page is still online and marked experimental. Start without it, and only add it if you're on an older 16.x release and nothing animates.
Why doesn't my animation run when I press the browser back button?
Back and forward buttons don't carry a transition type, so any animation you mapped to nav-back or nav-forward won't play. Untyped animations, such as a named shared-element morph, can still run. Only links you tag with transitionTypes get directional motion.
Does ViewTransition work in Safari and Firefox?
Yes. Same-document view transitions have been Baseline across Chrome, Edge, Firefox and Safari since October 2025, and Firefox added transition types in version 147. The Next.js guide notes that some animations can behave differently in Safari, so test there. Browsers without support skip the animation and navigate normally.
Start with one morph
You don't need all three effects on day one. Add the shared product image from step 1 to your busiest page, make sure the destination is prefetched and cached, and watch it work. Then add direction, then loading states.
If you want the morph to play every time, the missing piece is usually caching. Read my practical guide to use cache in Next.js 16 next.