How to Add Page Transitions in Next.js with 2 Lines of Code

Create page transitions and animations with React’s ViewTransition, without external libraries.

A Pokemon gallery used to demonstrate page transitions in Next.js

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.

A global page transition between the gallery and a Pokemon detail page.
// 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.

A Pokemon image morphing from its gallery card into the detail page.
// 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 name field on both views must be the same and unique per page.
  • The share field 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 name tag, 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.

Forward and backward navigation between Pokemon detail pages.
// 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.

The Pokemon grid crossfading when the sort tab changes.
// 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=auto and enter=auto props tell React to use its default crossfade animation.
  • The key={active} change is what triggers the transition.
  • The name prop 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 :)