View Transitions in Next.js 16 with React's ViewTransition
Add page transitions and shared element morphs to a Next.js 16 site with React's ViewTransition, plus fixes for fixed headers, backdrop-filter blur and reduced motion.
Page transitions in Next.js used to mean an animation library, a client-side wrapper around every route, and a lot of mount and unmount bookkeeping. With Next.js 16 you can hand the whole job to the browser. React's <ViewTransition> component drives the native View Transitions API, and the App Router runs every navigation inside a transition, so the animation fires on its own.
I shipped this on my portfolio (opens in a new tab) a few months ago: the old page fades out, the new one rises in, and a project's logo morphs from its card on the homepage into the hero of its case study. The setup took about ten lines. Making it look right next to a fixed, blurred navigation bar took considerably longer, and that part is most of this post.
Enable the flag
View transition support is still behind an experimental flag:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
viewTransition: true,
},
};
export default nextConfig;You do not need to install a React canary. The App Router already runs on the canary build that Next.js vendors, and that build exports ViewTransition:
import { ViewTransition } from "react";Browsers without the API just swap pages instantly, so there is nothing to polyfill.
TypeScript
There is usually nothing to add. @types/react 19.3 declares ViewTransition and addTransitionType in its stable types, and on older 19.2 types Next.js fills the gap: next-env.d.ts references Next's own types, which pull in React's experimental ones.
If you still see Module '"react"' has no exported member 'ViewTransition', you are on older types in a TypeScript program that skips next-env.d.ts, such as a shared UI package in a monorepo or a Storybook config with its own include. Update @types/react, or add the reference once in that project:
/// <reference types="react/experimental" />A page crossfade with template.tsx
A template.tsx file is like a layout, except it remounts on every navigation. That makes it the natural home for a page transition: the outgoing page unmounts and the incoming one mounts inside the navigation's transition, which is exactly what <ViewTransition> listens for.
import { ViewTransition } from "react";
export default function Template({ children }: { children: React.ReactNode }) {
return (
<ViewTransition enter="page-in" exit="page-out" default="none">
{children}
</ViewTransition>
);
}enter and exit are class names React puts on the transition, so the animations live in plain CSS:
::view-transition-old(.page-out) {
animation: page-fade-out 200ms ease-out both;
}
::view-transition-new(.page-in) {
animation: page-fade-in 240ms ease-out both;
}
@keyframes page-fade-out {
to {
opacity: 0;
}
}
@keyframes page-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
}default="none" matters more than it looks. Without it, this ViewTransition animates during every transition on the page, including unrelated ones such as a shared element morph, and the whole page ends up crossfading when only one element should move.
Morph a shared element between pages
This is the effect that makes view transitions worth the trouble. Give an element on one page and an element on the next page the same name, and the browser animates one into the other: position, size and all.
On my homepage, each project card wraps its logo:
<ViewTransition name={`work-hero-${slug}`} share="work-morph" default="none">
<div className="flex items-center justify-center">
<WorkMark mark={mark} size={36} />
</div>
</ViewTransition>;And the case study page wraps its hero logo with the same name:
<ViewTransition
name={`work-hero-${study.slug}`}
share="work-morph"
default="none"
>
<div className="absolute inset-0 flex items-center justify-center">
<WorkMark mark={study.mark} size={84} />
</div>
</ViewTransition>;The two elements do not need to share a size, a position or even a parent. The browser measures both, then animates the old one into the new one.
Names have to be unique on a page, so build them from the slug. share="work-morph" adds a class to the morph so you can tune it without touching other transitions:
::view-transition-group(.work-morph) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}Do not count on the reverse. In my testing, the browser's back and forward buttons did not start a view transition at all, so the logo did not morph back into its card. The morph plays for navigations that run inside a transition, such as Link clicks and router.push().
Where it gets harder: fixed chrome and backdrop-filter
My portfolio has a floating dock at the bottom of the screen with a backdrop-filter blur behind it. The first version of the transition above broke it in two ways, and they are worth understanding because any site with a sticky, blurred header will hit the same thing.
The blur disappears during every navigation
By default the browser captures the whole page as a snapshot named root, and it animates snapshots rather than the live page. backdrop-filter does not survive that capture, so for the length of the transition the dock sat on a sharp, unblurred background and then snapped back once live rendering resumed.
The fix is to opt the root out of capture:
html {
view-transition-name: none;
}Now only elements with their own names get captured. Everything else, including the dock, keeps rendering live throughout. The page crossfade still works because the ViewTransition in template.tsx gives the page its own name.
Captured content paints over the fixed bar
The second problem is subtler. Snapshots are drawn in an overlay that sits in the browser's top layer, above everything in the document, including your fixed header or dock. A backdrop-filter in the live page cannot blur pixels painted above it, so the incoming page slid over the dock unblurred.
The fix is to keep the incoming page out of the overlay. Only the exit uses the View Transition API. The incoming page animates in the live document with a normal CSS animation, so it stays underneath the dock and gets blurred from the first frame:
import { ViewTransition } from "react";
import { PageEnter } from "@/components/page-enter";
export default function Template({ children }: { children: React.ReactNode }) {
return (
<ViewTransition exit="page-out" default="none">
<PageEnter>{children}</PageEnter>
</ViewTransition>
);
}The outgoing snapshot still paints over the dock while it fades, so keep the exit short. At 200ms with an ease-out curve, most of it is gone within the first few frames.
Do not try to hide that overlap by masking or clipping the overlay. I first gave ::view-transition a mask-image that faded it out above the dock, and in Chrome that made the entire overlay invisible: no exit fade and no shared element morph, only the new page appearing. A clip-path on ::view-transition did the same. A mask on an inner pseudo-element such as ::view-transition-old() keeps the overlay visible, but it is sized to the captured element rather than the viewport, so it cannot line up with a fixed bar.
The Next.js docs suggest another option for headers that should stay perfectly still: give the header its own viewTransitionName and disable its animation. That works well for opaque headers. It does not help with a blurred one, because a named header gets captured too, and the capture loses the blur.
Skip the animation on first load
PageEnter has one more job. A template also mounts on the initial page load, and a site with its own load-in choreography does not want the page transition stacked on top of it. A module-scope flag survives template remounts, which makes it a cheap way to tell a first load from a client-side navigation:
"use client";
import { useEffect, useState } from "react";
let hasNavigated = false;
export function PageEnter({ children }: { children: React.ReactNode }) {
const [animate] = useState(() => hasNavigated);
useEffect(() => {
hasNavigated = true;
}, []);
return <div className={animate ? "page-enter" : undefined}>{children}</div>;
}.page-enter {
animation: page-fade-in 240ms ease-out backwards;
}One warning if you animate with transform here. A transformed ancestor becomes the containing block for any position: fixed descendant, so a fixed sidebar or table of contents inside the page suddenly scrolls with it. Keep the wrapper to opacity, and put the translateY on <main> or another element that has no fixed children.
Respect reduced motion
A global reduced-motion rule written with * does not reach view transition pseudo-elements, so you need to target them separately:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}With the durations at zero, navigation swaps instantly, which is what the browser does without the API.
Directional transitions with transitionTypes
Since Next.js 16.2, <Link> accepts a transitionTypes prop, and <ViewTransition> can pick an animation per type:
<Link href="/work" transitionTypes={["nav-back"]}>
All work
</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"
>
{children}
</ViewTransition>;router.push() and router.replace() accept transitionTypes too. Browser back and forward buttons do not carry a type, and in my testing they did not start a view transition at all, so directional slides and morphs only play for links and router calls.
Common problems
The shared element vanishes mid-morph
This one caught me twice. The browser animates a live picture of the new element, so whatever the target is doing during the transition shows up inside the morph. My case study logo first sat inside a scroll-reveal wrapper that starts at opacity: 0, so the card's logo flew across the screen and disappeared. After I removed the wrapper, the logo's own entrance animation (a 750ms fade and rise) did the same thing more subtly: the morph finished in 300ms while the target was still nearly transparent. Keep entrance animations off the morph target when the page is reached by navigation, and run them only on direct loads:
/* .page-enter is only present after a client-side navigation */
.page-enter .hero-logo {
animation: none;
}Some links skip the transition entirely
My icon buttons rendered internal links as plain <a> tags. A plain anchor triggers a full page load, and a full load has no transition to animate. Every internal link that should animate has to be a next/link Link, including ones rendered through a component's render or asChild prop.
DevTools shows view-transition-name: none
React sets the names only while a transition is running and removes them afterwards, so inspecting the element at rest shows none. That is expected, not a missing style.
Nothing animates at all
Check that experimental.viewTransition is on and that you restarted the dev server afterwards. Then check what triggers the update: <ViewTransition> only reacts to transitions, <Suspense> reveals and useDeferredValue. A plain setState swaps instantly by design.
The page swaps but the transition is invisible
If the transition runs (document.startViewTransition is called and its ready promise resolves) but you only ever see the new page, look for a mask-image or clip-path on ::view-transition. Either one hid the whole overlay in Chrome. Move the effect to an inner pseudo-element or remove it.
The whole page crossfades when only one element should morph
A ViewTransition without default="none" joins every transition. Add it to the page-level wrapper.
Two elements with the same name
View transition names must be unique among the elements on the page at capture time. A list that renders the same name twice, for example one card in a carousel and a copy in a modal, makes the browser skip the transition entirely. Include an id or slug in every name.
Fixed elements jump or scroll during the transition
Either a transform on a wrapper is acting as the containing block (see above), or the fixed element is being captured in the root snapshot. html { view-transition-name: none; } keeps it live.
Conclusion
Turn on the flag, wrap template.tsx in <ViewTransition> with default="none", and give shared elements matching names. That covers most sites. If you have fixed or blurred chrome, opt the root out of capture, keep the enter animation in the live DOM, and keep the exit short. With those three fixes the transitions look like part of the page instead of something happening on top of it.
Have a wonderful day.