Route changes in single-page applications often replace content instantly, making navigation feel abrupt. Creating polished transitions traditionally requires coordinating old and new content with CSS, JavaScript, or an animation library.
View Transition is developed to solve all that cases. React’s <ViewTransition> component integrates with the browser’s View Transitions API, while Next.js connects it to App Router navigations. This guide uses Next.js 16 App Router with React 19.2. Because the Next.js integration is still experimental, enable it in next.config.ts:
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
viewTransition: true,
},
};Motion, formerly Framer Motion, is a popular choice for page transitions in Next.js. Its full motion component adds around 34 KB to the JavaScript bundle, although LazyMotion can reduce the initial size significantly. For the transitions covered here, React’s <ViewTransition> provides a browser-native alternative without adding another animation dependency.
We will be discussing 4 different techniques related to View Transition.
- Global page transition
- Morph image between routes
- Directional navigation (Forward/Backward)
- Crossfade content
Explore the source code on GitHub and try the live View Transition demo.
1. Add a Global Page Transition
If you only need a simple site-wide crossfade animation, wrap the page content in <ViewTransition> inside the root layout. The header and footer remain outside the boundary, so they stay fixed while the page content transitions.
This approach is shown as a standalone option and is not enabled in the demo repository. I use this approach on this portfolio website and Ila's Kitchen, where a subtle site-wide crossfade works better than route-specific animation.
ViewTransition animations are activated by Transitions, <Suspense>, and useDeferredValue.
// app/layout.tsx
import { ViewTransition } from "react";
<body>
<div className="flex min-h-screen flex-col">
<SiteHeader />
<ViewTransition>
<div className="flex flex-1 flex-col">{children}</div>
</ViewTransition>
<SiteFooter />
</div>
</body>;Note: Do not add a global <ViewTransition> along with any other animation technique. Without explicit scoping, they can produce overlapping or unintended animations.
2. Morph an Image Between Routes
In motion design, when an object persists across a cut, it communicates continuity. The viewer understands they are looking at the same thing, not a replacement.
We can implement this using the following example. The gallery displays Pokémon images in a grid. Clicking a photo opens a detailed page with a larger version of the same image.
// Thumbnail component
import { ViewTransition } from "react";
<ViewTransition name={`pokemon-${pokemon.id}`} share="morph">
<Image
src={pokemon.image}
alt={formatPokemonName(pokemon.name)}
width={180}
height={180}
priority={priority}
className="h-44 w-44 object-contain transition group-hover:scale-105"
/>
</ViewTransition>
// Details page component
<ViewTransition name={`pokemon-${pokemon.id}`} share="morph">
<Image
src={pokemon.image}
alt={formatPokemonName(pokemon.name)}
width={360}
height={360}
priority
className="h-72 w-72 object-contain sm:h-80 sm:w-80"
/>
</ViewTransition>
// globals.css
/* Morph animations */
::view-transition-group(.morph) {
animation-duration: 400ms;
}
::view-transition-image-pair(.morph) {
animation-name: via-blur;
}
@keyframes via-blur {
30% {
filter: blur(3px);
}
}- The
namefield on both views must be the same and unique per page. - The
sharefield is used to pass custom class name, which can be used in CSS file. - You can see the demo by going to https://nirajrajgor.github.io/view-transition-nextjs/ website and clicking any Pokemon card.
- To decide which element should have
nametag, ask what visual element should feel continuous between pages.
Note: In this demo, the morph transition is used when navigating from the home page to a Pokémon detail page. Directional transitions are used only when moving between Pokémon detail pages. Applying both effects to the same route navigation felt visually busy, so each navigation uses one primary animation.
3. Directional navigation, i.e Forward and Backward
Moving left communicates forward navigation, while moving right communicates backward navigation. We can implement this as follows.
// At the root of component, app/pokemon/[name]/page.tsx
<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"
>
{/* page content */}
</ViewTransition>
// At bottom in same file, back nav
<Link
href={`/pokemon/${prev}`}
transitionTypes={["nav-back"]}
className="..."
>
{/* Link content */}
</Link>
// At bottom in same file, forward nav
<Link
href={`/pokemon/${next}`}
transitionTypes={["nav-forward"]}
className="..."
>
{/* Link content */}
</Link>
// globals.css
/* Nav Forward/Backward animations */
::view-transition-old(.nav-forward) {
--slide-offset: -60px;
animation:
150ms ease-in both fade reverse,
250ms ease-in-out both slide reverse;
}
::view-transition-new(.nav-forward) {
--slide-offset: 60px;
animation:
210ms ease-out 150ms both fade,
250ms ease-in-out both slide;
}
::view-transition-old(.nav-back) {
--slide-offset: 60px;
animation:
150ms ease-in both fade reverse,
250ms ease-in-out both slide reverse;
}
::view-transition-new(.nav-back) {
--slide-offset: -60px;
animation:
210ms ease-out 150ms both fade,
250ms ease-in-out both slide;
}
@keyframes fade {
from {
filter: blur(3px);
opacity: 0;
}
to {
filter: blur(0);
opacity: 1;
}
}
@keyframes slide {
from {
translate: var(--slide-offset);
}
to {
translate: 0;
}
}- The nav forward and backward must be at the top level of the page. You can’t nest it inside because the slide only plays when Next.js treats the navigation as the ViewTransition exiting (old page) and entering (new page).
- Browser-initiated back navigations (the back button or swipe gestures) do not carry a transition type, so the directional slide does not play.
During the directional slides, the Previous and Next buttons should not move. A sliding header breaks the user’s anchor. Assign the pagination container a viewTransitionName and suppress its animation in CSS.
<nav
className="..."
style={{ viewTransitionName: "pagination-nav" }}
>
{/* nav controls */}
</nav>// globals.css
/* Skip pagination nav when animating */
::view-transition-group(pagination-nav) {
animation: none;
z-index: 100;
}
::view-transition-old(pagination-nav) {
display: none;
}
::view-transition-new(pagination-nav) {
animation: none;
}Limitations: router.back() does not accept transitionTypes, so it cannot explicitly trigger the nav-back animation. Use a <Link> with a known destination when the directional animation is required.
We also observed inconsistent behavior in production when performing directional navigation on same page with Next.js.
It works in the demo because we exported static build and then hosted it on GitHub.
4. Crossfade Content on the Same Page
A crossfade communicates “same page, different content”. We will implement crossfade on the sorting tabs in the homepage.
If we click between tabs, the grid crossfades. The tab bar and surrounding layout do not move. Only the Pokemon grid transitions between states.
// pokemon-explorer.tsx file
<button
onClick={() =>
startTransition(() => {
addTransitionType("sort");
setActive(sort.id);
})
}
>
{sort.label}
</button>
// Wrap before tab and {active} is tab name
<ViewTransition
key={active}
name="my-content"
share="auto"
enter="auto"
default="none"
>
{/* Tab content */}
</ViewTransition>- Without
startTransition, there would be no animation at all, the cards would just snap into the new order. - The
share=autoandenter=autoprops tell React to use its default crossfade animation. - The
key={active}change is what triggers the transition. - The
nameprop gives the container an identity so React knows what to animate.
Note: Because each card already has a morph transition, disable it for the sort transition type to prevent the animations from mixing.
// app/_components/pokemon-card.tsx
<ViewTransition
name={`pokemon-${pokemon.id}`}
share={{ sort: "none", default: "morph" }}
default="none"
>
<Image {...} />
</ViewTransition>Browser Support and Reduced Motion
The browser View Transitions API is supported by current major browsers, although animations may behave differently across implementations. When the API is unavailable, the application continues to navigate normally and simply skips the transition.
React does not automatically disable View Transition animations for users who prefer reduced motion. Use the prefers-reduced-motion media query to remove animation durations and delays:
// globals.css
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}This preserves navigation behavior while preventing unnecessary movement for users who have requested reduced motion.
Thanks for reading. Hope you find it useful :)
