Ship route animations with zero JS libraries. Turn on Next.js 16's experimental.viewTransition, wrap elements in React 19's <ViewTransition>, and let the compositor handle shared-element hero morphs on the GPU.
The Next.js View Transitions API lets you animate DOM changes between routes using React 19.2's <ViewTransition> component plus the browser's native document.startViewTransition primitive, with zero JavaScript animation libraries in the client bundle. In Next.js 16 you flip experimental.viewTransition, wrap the moving element in <ViewTransition>, give it a unique viewTransitionName, and the router hands off between screens using compositor-only animations that run on the GPU. I've been shipping this in production dashboards since April, and honestly, the timing traces are genuinely surreal: 4 ms scripting on route change instead of the 90–140 ms Framer Motion mount cost.
React 19.2 ships <ViewTransition> as an experimental component; Next.js 16 exposes it via experimental.viewTransition: true in next.config.ts.
The API is a thin React wrapper over the browser's startViewTransition() primitive. Animations run on the compositor, so main-thread scripting stays near zero.
Same-document (SPA-style) transitions ship in Chrome 111+, Edge 111+, and Safari 18. Cross-document transitions require Chrome 126+ and are still behind a flag in Firefox as of July 2026.
You must set a unique view-transition-name per animated element per snapshot. Reused names collapse into a single group and silently break the animation.
Wrap the transition in startTransition() or trigger it via router.push(). Synchronous state updates outside a transition bypass the API entirely.
Always gate motion behind @media (prefers-reduced-motion: reduce). The API respects the OS setting but only if your CSS opts in.
What is the View Transitions API?
The View Transitions API is a browser primitive that snapshots the current DOM, lets you mutate it synchronously, then cross-fades between the old and new snapshots using CSS animations that run entirely on the compositor thread. It landed in Chrome 111 for same-document use in March 2023, gained cross-document support in Chrome 126, and shipped in Safari 18 in September 2024. React 19.2 wraps this primitive in a <ViewTransition> component so you can declare animations declaratively next to the JSX they animate, without ever touching the imperative document.startViewTransition() callback yourself.
The reason performance engineers care: the animation happens on the compositor with GPU-backed pseudo-elements (::view-transition-old and ::view-transition-new). Your main thread is free during the entire animation. That's categorically different from library-driven animation like Framer Motion, where every frame runs a React render plus layout plus paint cycle. See the MDN View Transitions API reference for the underlying primitive semantics.
In an App Router app, that means route transitions become genuinely free from the JS runtime's perspective. I profiled a dashboard sidebar navigation last month and the total scripting time on transition dropped from 118 ms (with a popular animation library) to 3.8 ms. The remaining cost is just the router state update and the startViewTransition wrapper. If you're new to reading these traces, our Suspense and streaming guide walks through the Performance panel with the same overlay style used here.
How do you enable view transitions in Next.js 16?
You turn on view transitions in Next.js 16 by adding experimental.viewTransition: true to next.config.ts, which teaches the App Router to call document.startViewTransition() around client-side navigations and unlocks the <ViewTransition> component in your components. The flag is experimental as of Next.js 16.2 (July 2026) but has been stable in the canary channel since 16.0, and it's on the roadmap to become default-on in 17.
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
// Wraps App Router navigations in document.startViewTransition
// and unlocks the React 19 <ViewTransition> component.
viewTransition: true,
},
};
export default config;
You also need React 19.2 or later. Earlier 19.x releases had the component behind an internal export that Next.js 16 refuses to bind. Check your package.json:
Once enabled, every router.push(), router.replace(), and <Link> click automatically opens a view transition. You don't need to change your navigation code (the compositor snapshot fires before the App Router unmounts the old page's tree). Combine this with the auto-memoization from the React Compiler in Next.js 16 guide, and the transition trigger path stays free of avoidable re-renders that would otherwise fight the compositor snapshot.
The React 19 <ViewTransition> component in practice
The <ViewTransition> component is a React primitive that assigns a view-transition-name to its single child element. Give two elements on different routes the same name, and the browser interpolates their positions, sizes, and content between snapshots. That's the entire mental model. Everything else is CSS.
// app/products/page.tsx
import { unstable_ViewTransition as ViewTransition } from "react";
import Link from "next/link";
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul className="grid grid-cols-3 gap-4">
{products.map((p) => (
<li key={p.id}>
<Link href={`/products/${p.id}`}>
{/* The name MUST be unique across the page */}
<ViewTransition name={`product-image-${p.id}`}>
<img src={p.image} alt={p.name} className="rounded-xl" />
</ViewTransition>
<h3>{p.name}</h3>
</Link>
</li>
))}
</ul>
);
}
On the detail page, wrap the hero image in a <ViewTransition> with the matching name:
Click a thumbnail and the browser will animate the grid cell expanding into the full hero. No keyframes, no useLayoutEffect, no measurement code. The view-transition-name is the entire contract. If you want to see it in the Performance panel, look for a purple "View Transition" track: you'll see the two snapshots and the interpolation, with zero React commits between them. That's the trace that convinced me to delete a 55 KB Framer Motion dependency from a checkout flow.
Shared element transitions across routes
Shared-element transitions are the flagship use case: a card on a listing page morphs into a hero on the detail page. The rules that trip people up: the name must be unique per snapshot, both elements need identical box-model coverage during the frame the snapshot is taken, and the animation ignores any elements that don't have a view-transition-name assigned (they simply fade). If you're building product grids, spec pages, or dashboard drilldowns, this is the pattern you want. It also composes nicely with our parallel and intercepting routes guide for modal-style drilldowns where the modal itself needs a shared transition with the underlying card.
Customize the transition timing with pure CSS in app/globals.css:
/* Apply to every named transition */
::view-transition-group(*) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
/* Target one specific transition by name */
::view-transition-group(product-image-42) {
animation-duration: 500ms;
}
/* Fade the old page out slightly faster than the new one fades in */
::view-transition-old(root) {
animation-duration: 200ms;
}
::view-transition-new(root) {
animation-duration: 400ms;
}
For dynamic lists, generate names from stable IDs. Never from array indices, since deletions will reuse names on the next render and produce ghosting. (I hit this exact bug shipping a filterable table, and it took an hour to trace back to the index-based key.) If you're pairing this with streaming data, keep the transition boundary consistent while Suspense boundaries resolve underneath. The snapshot happens at the exact moment the router commits the new tree, so any not-yet-resolved chunks will use their loading.tsx skeleton in the "new" snapshot, then swap in without triggering a second transition. That's a feature, not a bug. It means the animation completes even if data is slow.
Do view transitions work in Safari and Firefox?
Same-document (SPA) view transitions work in Chrome 111+, Edge 111+, and Safari 18 (September 2024). Cross-document transitions, the ones that fire between MPA-style hard navigations, need Chrome 126+ and remain behind the layout.css.view-transitions.enabled flag in Firefox as of Firefox 128 (July 2026). For a Next.js App Router app you almost always want same-document transitions, since client-side navigation is the default, so support is effectively "everywhere except Firefox stable."
Browser
Same-document
Cross-document
Fallback behavior
Chrome / Edge 126+
Yes (111+)
Yes
N/A
Safari 18+
Yes
Yes (18.2)
N/A
Firefox 128
Behind flag
Behind flag
Instant navigation, no animation
Safari 17 / iOS 17
No
No
Instant navigation, no animation
The critical property: unsupported browsers do not error. The React component checks for document.startViewTransition and falls through to a normal render if the API is missing. Your app functions identically; users on Firefox just get an instant screen swap instead of an animation. That's exactly the progressive-enhancement contract you want for a visual polish feature. Track feature adoption via caniuse.com/view-transitions. As of July 2026, same-document support sits at about 86% of global users. My rule of thumb: if a feature has more than 80% support, ship it and let unsupported browsers gracefully degrade rather than shipping polyfills that cost bundle bytes for everyone.
How is <ViewTransition> different from Framer Motion?
The short version: <ViewTransition> is a browser primitive, and Framer Motion is a JavaScript animation library. That distinction cascades into every metric that matters, including bundle size, main-thread work, layout stability, and battery drain on mobile.
Dimension
<ViewTransition>
Framer Motion
Client JS added
~0 KB (React built-in)
~55 KB gzipped for motion + layoutId
Scripting per transition
~4 ms (single snapshot)
~90–140 ms (per-frame render loop)
Runs on compositor
Yes (GPU-backed pseudo-elements)
Partial (only transform/opacity)
Shared element (layoutId)
Yes, via matching names
Yes, via layoutId
Cross-document animation
Yes (Chrome 126+)
No
Fine-grained gesture control
No
Yes (drag, pinch, spring physics)
Browser fallback
Silent no-op
Consistent everywhere
Use <ViewTransition> for route changes, mount/unmount animations, and shared-element hero transitions. That's the 90% case. Reach for Framer Motion when you need gesture-driven UI (drag-to-dismiss, swipe cards, physics-based spring layouts) that the platform doesn't offer. The two coexist happily. I've shipped apps that use view transitions for navigation and Framer for a draggable Kanban board on the same page. The important part is not to duplicate work: don't animate the route change with Framer's AnimatePresence when the router is already firing a view transition, or you'll get double animations and doubled scripting cost.
Why is my view transition not firing?
Nine times out of ten it's one of five causes. Open DevTools Performance, record a navigation, and look for the "View Transition" track. If it's absent, the browser never called startViewTransition in the first place. So, here's the debug checklist I run through, in order:
Duplicate view-transition-name: two elements with the same name in one snapshot silently disables that transition. Give each animated element a unique name (typically incorporate the record ID).
Missing experimental.viewTransition flag: without the config flag, Next.js router navigations skip the API entirely, even if <ViewTransition> is rendered.
Element is display: contents or has no box: the browser can't snapshot something with no layout box. Wrap the ViewTransition in a real element like a <div> with intrinsic size.
Navigation isn't inside a transition: imperative window.location.assign() bypasses the router. Use router.push() from next/navigation, or wrap manual state updates in startTransition().
User has reduced motion enabled: the API respects prefers-reduced-motion: reduce and will skip animation frames (not the transition itself, but any visible duration). That's the correct behavior. See the accessibility section below.
You can also opt individual transitions in and out at runtime with the update prop, which was added in React 19.2. Set it to "none" to opt an element out even when its parent is transitioning. That's useful for elements like tooltips or toasts that shouldn't animate along with the route change:
<ViewTransition name="cart-toast" update="none">
<Toast message="Added to cart" />
</ViewTransition>
For deeper debugging, enable "Show frame render events" in the Performance panel to see the snapshot commit as a discrete tick. A healthy transition shows two paint events roughly 300 ms apart with almost nothing between them. A broken transition shows a normal paint followed by a stream of React commits, which means startViewTransition never ran and you fell back to a plain re-render.
Accessibility and reduced motion
The View Transitions API respects the user's OS-level "reduce motion" setting, but only if you write CSS that opts in. By default the browser will still run your animations at reduced speed rather than skipping them entirely. Wrap all custom transition CSS in a prefers-reduced-motion query and provide an escape hatch:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
/* Cut duration to near-zero. Still fires the transition
so state stays correct, but skips the visible animation. */
animation-duration: 1ms !important;
animation-delay: 0ms !important;
}
}
Don't set animation: none outright. That can leave elements in an inconsistent snapshot state on Safari 18.0 because the browser skips the interpolation step entirely and hands off before the new snapshot commits. Setting a 1 ms duration gives the compositor a coherent frame to commit without any perceived motion. Test with the DevTools "Emulate CSS media feature prefers-reduced-motion" toggle before shipping, and audit vestibular-triggering movement (large translates, spins, parallax) with real users when possible.
Also consider the @media (update: slow) query for low-power devices. You may want to skip transitions on e-ink displays or older mobile devices where the compositor cost isn't free. That's the same class of consideration you'd apply when tuning bundle splits or React Compiler cache behavior, and the pattern generalizes: measure before you animate, and give the platform every chance to opt out.
Frequently Asked Questions
Does React 19 view transitions work in server components?
Yes. The <ViewTransition> component works in both server and client components because it compiles to a plain DOM element with a style attribute setting view-transition-name. There is no client-side runtime for the tag itself; the router handles the startViewTransition call from the client. That means you can annotate server-rendered product cards without shipping any extra JavaScript.
Is <ViewTransition> stable in production?
As of React 19.2, the component is exported as unstable_ViewTransition, and Next.js 16 gates it behind experimental.viewTransition. The underlying browser API is stable and shipping. I've been running it in production since April 2026 without regressions, but expect the import path to change when it drops the unstable_ prefix in React 19.3.
Does Next.js Link automatically trigger view transitions?
Yes, once you set experimental.viewTransition: true. Every <Link> click, router.push(), and router.replace() is automatically wrapped in document.startViewTransition. You do not need to opt in per link, because the App Router opts in for the whole navigation layer at once.
How do I disable view transitions for a specific navigation?
Wrap a specific element in <ViewTransition update="none"> to opt it out of the running transition, or use the imperative browser API with document.startViewTransition({ types: ['skip'] }) and detect the type in CSS to skip animation. For a full route-level opt-out, target ::view-transition-group(root) in a route-scoped stylesheet with a 1 ms duration.
What browsers support cross-document view transitions?
Cross-document transitions (between full page loads, not client-side navigations) require Chrome 126+, Edge 126+, and Safari 18.2+. Firefox has the feature behind layout.css.view-transitions.enabled as of Firefox 128 but ships it disabled by default. Same-document (SPA-style) transitions have much broader support, including Safari 18 stable and all Chromium-based browsers since 111.
Learn how to install Better Auth in Next.js 16, wire up Drizzle or Prisma, add email/password plus Google and GitHub social login, and lock down routes with proxy.ts and Server Action session checks.
Prefetch on the server, hydrate on the client. A 2026 guide to using TanStack Query v5 with the Next.js 16 App Router: HydrationBoundary, streaming, Server Actions, and the common hydration errors that break RSC setups.
Real effort estimates and a phase-by-phase playbook for migrating from Gatsby to Next.js 16 App Router: GraphQL to Server Components, plugin mapping, dynamic routes, redirects, and deployment.