Type something to search...
Next.js Designing view transitions

Next.js Designing view transitions

Route changes on the web have historically had no visual continuity — one full set of elements disappears, an entirely unrelated set appears, and nothing on screen tells the user these two states are actually connected. Click a photo thumbnail to view it larger on another page, and despite it being the literal same image, the browser gives you zero visual signal of that continuity. Apps that wanted this kind of connective animation traditionally needed heavyweight animation libraries manually tracking element positions and mount/unmount lifecycles across route boundaries.

React's <ViewTransition> component, integrated with the browser's native View Transitions API, replaces that manual tracking with a declarative model: you name the elements that should persist across a transition, and the browser handles animating between their old and new positions itself. This article walks through the four patterns that cover the overwhelming majority of real use cases.

No configuration required

View transitions work in the App Router out of the box. The App Router already runs on React canary releases — which include every stable React 19 change plus newer capabilities like ViewTransition — so there's no separate react@canary install required on your end; it's already the version in use.

import { ViewTransition } from "react";

One browser-support caveat worth knowing precisely rather than glossing over: React's integration specifically depends on newer View Transitions API features — transition types and view-transition-class — available in Chromium 125+ and recent Safari/Firefox, with some real behavioral differences still present in Safari specifically. Critically, the failure mode here is graceful, not broken: without browser support, your app functions completely normally, just without the animation — there's no degraded experience or error state to handle, only the absence of the visual flourish.

The activation trigger matters precisely: <ViewTransition> animations fire from Transitions (useTransition), <Suspense>, and useDeferredValuenot from a plain setState call. Since Next.js route navigations are themselves Transitions under the hood, <ViewTransition> animations activate automatically during navigation with no extra wiring needed on your part for that specific case.

An installable skill, if you'd rather have an agent do this

Given how much of this article is CSS-heavy, pattern-based work, Vercel maintains a dedicated skill teaching a coding agent these exact patterns:

npx skills add vercel-labs/agent-skills --skill vercel-react-view-transitions

Worth knowing about specifically if you'd rather prompt for "morph this thumbnail into a hero image" or "slide between routes forward and back" than hand-implement the CSS yourself from the patterns below.

Pattern 1: shared element morphing

The core insight from motion design this whole pattern rests on: when an object visibly persists across a cut, it communicates continuity — the viewer understands they're looking at the same thing continuing, not one thing being replaced by an unrelated other. This is the single most impactful transition pattern available, and it's built on one simple mechanism: give the same name to a <ViewTransition> on both the "before" and "after" pages.

// components/photo-grid.tsx
function PhotoGrid({ photos }) {
  return (
    <div className="grid grid-cols-3 gap-3">
      {photos.map((photo) => (
        <Link key={photo.id} href={`/photo/${photo.id}`}>
          <ViewTransition name={`photo-${photo.id}`}>
            <Image src={photo.src} alt={photo.title} />
          </ViewTransition>
        </Link>
      ))}
    </div>
  );
}
// app/photo/[id]/photo-content.tsx
async function PhotoContent({ id }) {
  const photo = await getPhoto(id);
  return (
    <ViewTransition name={`photo-${photo.id}`}>
      <div style={{ position: "relative", aspectRatio: "3 / 2" }}>
        <Image src={photo.src} alt={photo.title} fill />
      </div>
    </ViewTransition>
  );
}

That's genuinely the entire mechanism — the name prop alone creates identity across the navigation. React finds matching names on the old and new pages and animates the size/position difference between them automatically, with no additional props required for the morph itself to function. Click a thumbnail, and the image visibly scales and repositions from its grid cell into the hero slot; navigate back, and the morph plays in reverse.

One structural constraint worth knowing before you rely on this everywhere: the morph only plays when the destination content renders in the same commit as the navigation — true for prefetched, cached pages. If the destination instead suspends into a fallback first, no matched pair forms at all, and the content simply plays its ordinary enter animation once it eventually arrives, with no morph.

Customizing the morph beyond the default

The morph works with zero CSS out of the box. To actually customize it, add share="morph" alongside default="none":

<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
  <Image src={photo.src} alt={photo.title} />
</ViewTransition>

default="none" here is doing something specifically worth understanding, not just copying: without it, every named <ViewTransition> on the page animates on every transition that occurs anywhere, not just the ones you actually intend it to respond to. Setting default="none" alongside an explicit share value scopes the animation correctly; setting default="none" without a share prop silently disables the morph entirely — a genuinely easy trap to fall into if you copy one half of this pairing without the other.

::view-transition-group(.morph) {
  animation-duration: 400ms;
}
::view-transition-image-pair(.morph) {
  animation-name: via-blur;
}
@keyframes via-blur {
  30% {
    filter: blur(3px);
  }
}

The blur keyframe here isn't decorative — it deliberately masks pixel-level interpolation artifacts that appear mid-morph as the browser interpolates between two differently-sized/positioned versions of the same image. 400ms is a deliberately chosen middle ground: slow enough to actually register as an intentional animation, fast enough to still feel direct and responsive rather than sluggish.

Pattern 2: Suspense reveals for loading states

When a Suspense boundary's fallback gets replaced by real content, an instant swap communicates nothing — the skeleton just vanishes and content pops in with no sense of handoff. The relevant motion-design principle here: vertical direction encodes hierarchy. Content sliding up reads as arrival; content sliding down reads as departure. Paired together, they create a genuine sense of handoff — the placeholder yielding its place to the real thing, rather than an abrupt cut.

// app/photo/[id]/page.tsx
export default async function PhotoPage({ params }) {
  const { id } = await params;
  return (
    <Suspense
      fallback={
        <ViewTransition exit="slide-down" default="none">
          <PhotoContentSkeleton />
        </ViewTransition>
      }
    >
      <ViewTransition enter="slide-up" default="none">
        <PhotoContent id={id} />
      </ViewTransition>
    </Suspense>
  );
}

The CSS timing here is deliberately asymmetric, and the asymmetry is the actual point, not an arbitrary choice:

::view-transition-old(.slide-down) {
  animation:
    150ms ease-out both fade reverse,
    150ms ease-out both slide-y reverse;
}
::view-transition-new(.slide-up) {
  animation:
    210ms ease-in 150ms both fade,
    400ms ease-in both slide-y;
}

Old content exits fast (150ms) — it should get out of the way quickly rather than lingering and competing for attention with the incoming real content. New content arrives more gently, with its fade specifically delayed until the exit finishes (the 150ms delay baked into the enter animation), giving the user a clean beat to register that something new has actually arrived, rather than two animations overlapping and blurring together into visual noise.

Pattern 3: directional navigation

With morphing and Suspense reveals in place, forward and back navigation still look identical — there's no signal at all telling the user which direction they just moved in the app's hierarchy. The relevant convention, borrowed directly from film and animation: horizontal direction encodes spatial position. Moving left reads as progressing forward; moving right reads as returning. This convention is genuinely deep enough in how people read motion that violating it tends to feel actively disorienting, not merely unconventional.

Tag your links with transitionTypes to declare the direction explicitly — this is not automatic; you decide which links represent "forward" versus "back" based on your own app's actual navigation hierarchy:

<Link href={`/photo/${photo.id}`} transitionTypes={['nav-forward']}>
<Link href="/" transitionTypes={["nav-back"]}>
  ← Gallery
</Link>

Then map those types to directional animations in a <ViewTransition> wrapping the page content:

<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>

default: 'none' inside the enter/exit objects here is what correctly excludes navigations carrying no explicit type at all — the browser's native back/forward buttons, a bare router.refresh() — from playing any directional animation, since those genuinely have no declared direction to honor.

Where this wrapper goes matters mechanically, not just stylistically: it needs to live in each participating page.tsx, not in a shared layout. Layouts persist unchanged across a navigation by design — there's no enter/exit transition to fire on a component that never actually unmounts and remounts in the first place.

Keeping the header anchored during slides

A header that slides along with directional content breaks the user's one fixed spatial reference point — they need something visibly stationary to correctly read "the content moved," rather than perceiving the entire viewport as having shifted:

<header style={{ viewTransitionName: '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;
}

The display: none on the old snapshot specifically prevents a brief double-header flash where both the old and new header would otherwise be simultaneously visible during the transition; z-index: 100 keeps the stationary header correctly layered above the sliding content beneath it.

Two operational details worth not skipping

Pointer events during a transition. The ::view-transition overlay captures pointer events by default while active, which means clicks during the animation are simply lost, dropped. Restore interactivity for unnamed content explicitly:

::view-transition {
  pointer-events: none;
}

Hit-testing still skips named participants (your anchored header, say) for the transition's duration regardless — which is exactly why keeping transitions short, and being deliberate about which elements you actually name, matters for interactive elements a user might click rapidly.

Reduced motion. Directional slides simulate literal physical movement across the viewport, which makes them the single most common trigger among these patterns for motion sensitivity — meaningfully more so than morphs or crossfades, which affect smaller areas or rely on opacity rather than large positional shifts. The simplest, safest fix:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*),
  ::view-transition-group(*) {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
  }
}

This isn't optional polish for a production app — it's the difference between a nice motion detail and a genuine accessibility hazard for users who've explicitly told their OS they need reduced motion.

Pattern 4: same-route crossfades

Tabs that share one route (/collection/[slug] switching between different photographer slugs, say) need a fundamentally different signal than the slide pattern above — a directional slide says "going to a new place," which is simply the wrong message for switching content within the same container. A crossfade says the correct thing instead: "same place, different content."

// app/collection/[slug]/page.tsx
export default async function CollectionPage({ params }) {
  const { slug } = await params;
  return (
    <Suspense fallback={<CollectionGridSkeleton />}>
      <ViewTransition
        key={slug}
        name="collection-content"
        share="auto"
        enter="auto"
        default="none"
      >
        <CollectionGrid slug={slug} />
      </ViewTransition>
    </Suspense>
  );
}

The mechanism worth understanding precisely here: key={slug} is what makes React treat the old and new content as a genuine exit/enter pair — activating share — rather than as an ordinary in-place update it would otherwise just patch quietly with no transition at all. share="auto" and enter="auto" tell React to apply its sensible built-in crossfade rather than requiring you to hand-author custom keyframes for this specific case, the way the earlier patterns did.

The four patterns, and what each one actually communicates

PatternWhat it tells the user
Shared element morph"Same thing, going deeper"
Suspense reveal"Data has loaded"
Directional slide"Going forward / coming back"
Same-route crossfade"Same place, different content"

Key Takeaways

ConcernAnswer
Extra install requiredNo — App Router already runs React canary, which includes ViewTransition
What triggers a <ViewTransition> animationTransitions, <Suspense>, useDeferredValue — not plain setState
Browser support gapGraceful — animations simply don't play; nothing breaks
Preventing unrelated transitions from firing a named elementdefault="none" — but always paired with an explicit share/enter/exit
Directional wrapper placementEvery participating page.tsx, never a shared layout
Motion-sensitivity riskHighest for directional slides — always add a prefers-reduced-motion override

The unifying idea across all four patterns is the same one from motion design generally: an animation should always be answering a specific question the user has — "is this the same thing?", "did my data load?", "which direction did I just go?", "am I still in the same place?" — rather than existing purely as decoration. Picking the right pattern is really just picking which of those questions actually needs answering at a given moment in your app.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper