Skip to content
Zumkai

View Transitions in Next.js 16: the three layers

One is Baseline, another skips Firefox and the third is experimental in React. Knowing which one you use decides what ships to production.

  • next.js
  • react
Card with the three layers of View Transitions and each one's maturity level in August 2026.
Contents
  1. Layer 1: same-document, and why it is the one with no caveat
  2. Layer 2: cross-document, and what Firefox does with it
  3. Layer 3: React's <ViewTransition>
  4. The warning that changes the decision
  5. The six limitations of the React component
  6. The four recipes, and what each one communicates
  7. Four traps the docs warn about
  8. What I would use in production today
  9. Frequently asked questions
  10. Sources

"View Transitions" became the name of three different things, with three levels of maturity. Confusing them is what leads someone to ship an API the React documentation calls not production-ready.

LayerWhat it isStatus today
document.startViewTransition()Transition inside the same pageBaseline, Chrome 111+, Safari 18+, Firefox 144+
The @view-transition at-ruleTransition between pages (MPA)Not Baseline, Firefox ignores it
React <ViewTransition> componentTransition orchestrated by ReactExperimental, Canary only

Next.js 16 gives you access to all three, because the App Router runs React's latest Canary. That is convenient and dangerous in equal measure: using the component without noticing it is unstable is easy.

Layer 1: same-document, and why it is the one with no caveat

It is the only one of the three that passed into Baseline this year.

The API is a single function. You hand it a callback that changes the DOM, and the browser captures the before, applies the change, captures the after and animates between the two:

js
document.startViewTransition(() => {
  // any DOM change here
  list.replaceChildren(...newItems)
})

Support today covers all three engines: Chrome 111+, Safari 18+ and Firefox 144+. No flag, no prefix.

Fine control comes through CSS, on the pseudo-elements the browser creates during the transition:

css
::view-transition-old(root) { animation: 0.4s ease-in both leave; }
::view-transition-new(root) { animation: 0.4s ease-in both arrive; }

For a shared element transition (the thumbnail that becomes the large image), the mechanism is view-transition-name. Two elements in different states with the same name, and the browser interpolates between them.

This layer is where I would put anything that has to work in production today.

Layer 2: cross-document, and what Firefox does with it

Here the transition happens between pages, in the traditional navigation flow.

The opt-in is an at-rule, and it has to exist in both documents:

css
@view-transition {
  navigation: auto;
}

The navigation descriptor takes auto or none. With auto, the document goes through a transition when four conditions hold at once:

  1. The navigation is same-origin
  2. No cross-origin redirect sits in the path
  3. The navigationType is traverse, push or replace
  4. For push and replace, the navigation was initiated by the user, not by the browser interface

That list explains most of the "it did not work" cases. A redirect passing through another domain takes the whole transition down, and navigation triggered by a browser button counts as nothing user-initiated.

And then there is Firefox. MDN classifies the at-rule as "Limited availability", outside Baseline. Firefox 144 supports the same-document API, and it ignores the @view-transition at-rule. A multi-page flow there never animates at all: navigation happens with the same hard cut as always.

That breaks nothing, it degrades. It is the difference between this layer and the next.

The same degradation reasoning holds for CSS scroll-driven animations: what degrades on its own is safe, what hides content needs a fallback.

Layer 3: React's <ViewTransition>

This is the one Next.js 16 delivers and the one demanding the most care.

The component wraps a subtree and animates when it enters, leaves or changes:

jsx
import { ViewTransition } from 'react'

<ViewTransition enter="auto" exit="auto" default="none">
  <Video />
</ViewTransition>

React activates the animation on its own, according to the kind of change:

PropFires when
enterThe <ViewTransition> gets inserted inside a Transition
exitIt gets removed inside a Transition
updateA DOM mutation or layout change happens inside it
shareA <ViewTransition> with the same name exists in the removed and the inserted tree
defaultFallback for every trigger above

The values are "auto", "none", a CSS class name, or an object with conditional logic, which allows a different animation for forward and backward navigation.

A shared element transition becomes declarative. Same name, different trees:

jsx
<ViewTransition name="cover"><div className="thumbnail" /></ViewTransition>
// ...on another screen
<ViewTransition name="cover"><div className="thumbnail fullscreen" /></ViewTransition>

And an imperative path exists, for anyone wanting the Web Animations API instead of CSS. The onEnter, onExit, onUpdate and onShare callbacks receive an instance with the .old and .new pseudo-elements:

jsx
<ViewTransition
  onEnter={(instance) => {
    const anim = instance.new.animate(
      [{ opacity: 0 }, { opacity: 1 }],
      { duration: 500 }
    )
    return () => anim.cancel()
  }}
/>

The warning that changes the decision

React's documentation is explicit about the stage:

Next.js 16 uses React's latest Canary in the App Router. That is why the component sits available to you with nothing installed, and it is why the warning is easy to miss. The availability comes from the framework; the stability does not.

What that means in practice: using <ViewTransition> today is accepting that the API can change in a minor release. For a client site under a maintenance contract, that is a debt you are taking on without saying so. For your own project or an experiment, it is acceptable.

Recording the other side is worth doing, because it is real: Vercel documents the component as a supported pattern, with an official guide of four recipes, and states that it works in the App Router with no configuration at all, so installing react@canary is unnecessary. An official skill even exists for teaching the recipes to an agent:

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

Both things are true at once. React calls it experimental; Next.js delivers, documents and supports it. The risk decision is yours, and it should be explicit in the budget instead of implicit in the code.

If the transition has to exist and cannot break, layers 1 and 2 do the same job with CSS and the native API.

The six limitations of the React component

All documented, and five of them produce a symptom with no error.

1. It has to be the outermost element. The component works only if it sits before any DOM node. Wrapping a <div> around it breaks the enter and exit animations.

2. One per name, at a time. Only one <ViewTransition name="X"> can sit mounted at any moment.

3. It demands a Transition. Nothing happens outside startTransition(), <Suspense> or useDeferredValue. An ordinary state change activates no animation. In Next.js that is less restrictive than it sounds: route navigation is already a transition, so the animations activate on their own during navigation.

4. DOM only. It works in no React Native.

5. flushSync skips the animation. A synchronous update ignores the whole transition.

6. Accessibility is manual. React checks prefers-reduced-motion for nobody.

The sixth deserves attention. Unlike CSS, where you write the media query once and it holds, here the check is your responsibility:

jsx
const reduced = useMediaQuery('(prefers-reduced-motion: reduce)')

<ViewTransition default={reduced ? 'none' : 'auto'}>
  <Content />
</ViewTransition>

Without that, you are animating for someone who asked the system, in so many words, to stop animating.

The four recipes, and what each one communicates

The official guide organizes by meaning, not by technique. It is the best editorial decision Vercel made in that documentation, and the criterion is worth copying.

PatternWhat it tells the user
Shared element morph"Same thing, going deeper"
Suspense reveal"The data arrived"
Directional slide"Moving forward / going back"
Crossfade on the same route"Same place, different content"

The morph is the simplest and the most valuable: the same name on both sides, and the browser interpolates size and position.

jsx
// in the grid
<ViewTransition name={`photo-${photo.id}`}>
  <Image src={photo.src} alt={photo.title} />
</ViewTransition>

// on the detail page
<ViewTransition name={`photo-${photo.id}`}>
  <Image src={photo.src} alt={photo.title} fill />
</ViewTransition>

No extra prop enters the picture. But a timing condition exists: the morph plays only when the destination renders in the same commit as the navigation, which happens with a prefetched page. If the destination lands in a Suspense fallback first, the pair never forms and the content arrives with the enter animation.

The directional slide is the one recipe depending on a Next.js-specific API. The transitionTypes prop on <Link> marks the navigation, and <ViewTransition> maps the type to the animation:

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

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

useRouter accepts transitionTypes in push() and replace() too. And the type is never automatic: you decide which links are "forward" and which are "back", according to your app's hierarchy.

Four traps the docs warn about

All of them produce a symptom with no error.

1. The wrapper in the layout never fires. Layouts persist between navigations, so enter and exit never happen there. The page's <ViewTransition> has to sit in each page.tsx.

2. default="none" without share kills the morph in silence. default="none" exists to stop a named <ViewTransition> from animating on every unrelated transition. But adding default="none" to a named pair without keeping the explicit share makes the pair stop morphing outright, with no warning.

3. Clicks during the transition get lost. The ::view-transition layer captures pointer events while animating. The fix is one line:

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

Even so, hit-testing keeps skipping the named participants during the transition, so keep the animations short and avoid naming an element the user clicks in quick succession.

4. Going back through the browser carries no type. Navigation initiated by the back button or a swipe gesture carries no transition type, so the directional slide never plays. The shared element morph keeps holding, because it depends on the name alone.

Anchoring the header also pays off, since otherwise it slides along and the user loses the spatial reference:

css
::view-transition-group(site-header) { animation: none; z-index: 100; }
::view-transition-old(site-header) { display: none; }

The display: none on the old snapshot prevents the flash of two headers visible at once.

What I would use in production today

The rule I follow has one line: use the most mature layer that solves the problem.

You wantUse
Animating a content swap on the same pageLayer 1, startViewTransition()
A transition between pages on a multi-page siteLayer 2, @view-transition, accepting that Firefox never animates
A shared element between two App Router screensLayer 3, if the project tolerates an unstable API
Reveal, parallax, progress barNone of that, use scroll-driven animations

And the fallback criterion, which holds for all three: how does it look if the transition never happens?

If the answer is "navigation gets a hard cut", everything is fine, since that is the behavior the web had for thirty years. If the answer is "the content stays invisible" or "the element sits in the wrong place", the problem has nothing to do with the transition; it is that you built the final state assuming the animation always runs.

That test is the one I apply to scroll-driven animations, and it fails the same kind of code: whatever starts from a hidden state.

If you have yet to update, what breaks in the Next.js 16 migration covers the other twenty changes that come along.

Frequently asked questions

Can I use View Transitions in production today?

The same-document layer, yes, with no caveat, since it is Baseline in all three engines. The cross-document one, yes, accepting that Firefox never animates and degrades to a hard cut. React's component, only if the project tolerates an API the documentation itself calls not production-ready.

Why does the transition between pages fail on my site?

Check layer 2's four conditions in order: same-origin, no cross-origin redirect, a compatible navigationType, and navigation initiated by the user. A redirect passing through another domain is the most common cause. After that, confirm the at-rule exists in both documents.

Does <ViewTransition> work without startTransition?

No. The component demands that the update happen inside a Transition, a <Suspense> or a useDeferredValue. An ordinary state change activates no animation, and no error points that out.

Does this replace GSAP for page transitions?

For a simple transition between states, yes, with no library. For a coordinated sequence of several stages, easing control per segment and synchronization with scroll, no. GSAP's license and scope cover when it stays the right tool.

Sources

Verified on 20 August 2026.

Review trigger: revisit when (a) React's <ViewTransition> leaves the Canary channel and stabilizes, (b) Firefox starts honoring the @view-transition at-rule, moving cross-document into Baseline, or (c) the component's props change before stabilization, which the documentation itself warns can happen.