DEV Community

Cover image for React View Transitions in Next.js 16.4: ViewTransition, addTransitionType and Tested Examples
Waseem Anjum
Waseem Anjum

Posted on Originally published at fabwaseem.com

React View Transitions in Next.js 16.4: ViewTransition, addTransitionType and Tested Examples

Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.

React View Transitions animate UI changes with the browser's View Transition API, declaratively. You wrap elements in <ViewTransition>, and React animates them when they enter, exit, update or move between pages. They became stable in React 19.3 on September 9, 2026, and Next.js 16.4 ships React 19.3, so they work in the App Router with no configuration.

I built a small Next.js 16.4.2 app with the four main patterns: a shared-element morph, a Suspense reveal, directional slides and a tab animation with addTransitionType. Then I recorded every transition in headless Chrome 155 to see what actually fires. Most of it behaves as documented. Browser back and forward do not, so that gets its own section below.

What are React View Transitions?

React View Transitions are the <ViewTransition> component and the addTransitionType function, stable since React 19.3. They hand animations to the browser's document.startViewTransition() from the View Transition API: the browser snapshots the old and new UI and animates between them, while React decides which elements take part and which CSS class each animation uses.

React picks the animation from how the tree changed. From the React 19.3 announcement:

Trigger When it runs Prop
enter The <ViewTransition> is added enter
exit The <ViewTransition> is removed exit
update Its children change style or content update
share A named <ViewTransition> is removed in one place and added in another share
(fallback) Any trigger without its own prop default

Each prop takes "auto" (the browser's crossfade), "none", a CSS class name you style, or an object that maps transition types to classes.

Four React View Transition patterns in Next.js: shared element morph with a matching name, Suspense reveal with exit and enter, directional slides with transitionTypes, and addTransitionType for tabs

What triggers a view transition in React?

Only Transitions do. React animates updates inside startTransition, a <Suspense> boundary revealing its content, and updates from useDeferredValue. A plain setState does not animate, because React treats it as urgent. In Next.js App Router, every <Link> and router.push() navigation is a Transition, so <ViewTransition> animates route changes automatically.

This is the mistake behind most "my view transition doesn't run" reports. If you call setIndex(2) directly in a click handler, nothing animates. Wrap it in startTransition(() => setIndex(2)) and it does.

Do View Transitions need a flag in Next.js 16.4?

No. The Next.js view transitions guide says they "work in the App Router with no configuration." The experimental.viewTransition option that older versions needed is gone: I searched Next.js 16.4.2's config schema and it has no viewTransition key. You need React 19.3, which next@16.4 installs.

Requirements and support on October 17, 2026:

Requirement Value
React 19.3.0 or newer (19.3.1 is latest)
Next.js 16.4 (ships React 19.3); App Router
Import import { ViewTransition, addTransitionType } from "react"
Browsers Chromium 125+, recent Safari and Firefox (per Next.js docs)
Unsupported browsers The app works; changes happen without animation
Platforms DOM only; React Native support is in progress

Next.js notes that "some animations may behave differently in Safari." Test there before you ship anything elaborate.

How do I add view transitions to a Next.js app?

Import ViewTransition from react, wrap the element you want to animate, and add CSS for the class names you pass. For route animations, give matching elements the same name on both pages, tag links with transitionTypes, and wrap each page.tsx (not the layout) in a <ViewTransition> that maps those types to animations.

How a Next.js navigation becomes a React view transition: Link with transitionTypes starts a Transition, React matches named ViewTransition pairs and enter/exit props, the browser snapshots old and new states and runs CSS on view-transition pseudo-elements

My demo is a grid of four cards. Each card opens a detail page with a large hero, details that stream in from the server, and a tabs page. It runs Next.js 16.4.2, React 19.3.1 and cacheComponents: true (the default for new 16.4 apps), so each detail page is a partial prerender: a static shell plus a dynamic hole. The patterns follow the official Next.js view transitions demo, trimmed down so each one is easy to see.

Step 1: Morph a shared element between pages

Give the thumbnail and the hero the same name. React pairs them across the navigation and the browser animates size and position:

// app/page.tsx
import { ViewTransition } from "react";
import Link from "next/link";

{items.map((item) => (
  <Link key={item.id} href={`/item/${item.id}`} transitionTypes={["nav-forward"]}>
    <ViewTransition name={`card-${item.id}`} share="morph" default="none">
      <div className="card" style={{ background: item.color }}>{item.title}</div>
    </ViewTransition>
  </Link>
))}
Enter fullscreen mode Exit fullscreen mode
// app/item/[id]/page.tsx
<ViewTransition name={`card-${item.id}`} share="morph" default="none">
  <div className="hero" style={{ background: item.color }}>{item.title}</div>
</ViewTransition>
Enter fullscreen mode Exit fullscreen mode

default="none" stops the named element from crossfading during unrelated transitions. Next.js warns that with default="none" you must keep the explicit share, or "the pair silently stops morphing." Names must be unique on a page, so build them from an ID.

The morph needs both elements in the same commit. Next.js says this happens with prefetched pages; if the destination suspends first, "no pair forms." In my app the hero sits in the static shell, which <Link> prefetches, so it morphed on every click.

Step 2: Animate a Suspense reveal

Wrap the fallback in an exit animation and the content in an enter animation:

// app/item/[id]/page.tsx
<Suspense
  fallback={
    <ViewTransition exit="slide-down" default="none">
      <div className="skeleton">Loading details…</div>
    </ViewTransition>
  }
>
  <ViewTransition enter="slide-up" default="none">
    <Details id={item.id} />
  </ViewTransition>
</Suspense>
Enter fullscreen mode Exit fullscreen mode

Details calls await connection(), so it renders at request time. When it streamed in, React started a second, separate view transition about 800 ms after the navigation one, which matches the delay I put in the data function.

Step 3: Slide forward and back

Tag links with transitionTypes, then map the types to classes in a wrapper you put in every page:

// components/directional.tsx
import { ViewTransition } from "react";

export function Directional({ children }: { children: React.ReactNode }) {
  return (
    <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"
    >
      {children}
    </ViewTransition>
  );
}
Enter fullscreen mode Exit fullscreen mode
<Link href="https://www.fabwaseem.com/" transitionTypes={["nav-back"]}>← All cards</Link>
Enter fullscreen mode Exit fullscreen mode

Use it in each page.tsx. Layouts persist across navigations, so enter and exit never fire on a wrapper placed there. useRouter().push() and replace() also accept transitionTypes.

Step 4: Pick the animation with addTransitionType

Outside navigation, call addTransitionType inside startTransition to tell React why the update happened. The same setIndex slides left or right depending on direction:

"use client";
import { ViewTransition, addTransitionType, startTransition, useState } from "react";

export function Tabs() {
  const [index, setIndex] = useState(0);

  function go(next: number) {
    startTransition(() => {
      addTransitionType(next > index ? "tab-next" : "tab-prev");
      setIndex(next);
    });
  }

  return (
    <>
      {tabs.map((t, i) => <button key={t} onClick={() => go(i)}>{t}</button>)}
      <ViewTransition
        key={index}
        enter={{ "tab-next": "from-right", "tab-prev": "from-left", default: "none" }}
        exit={{ "tab-next": "to-left", "tab-prev": "to-right", default: "none" }}
        default="none"
      >
        <div className="panel">{tabs[index]} panel</div>
      </ViewTransition>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

key={index} makes React treat each panel as a new element, so it runs exit on the old one and enter on the new one. React also adds each type to the browser's view transition, so CSS can match it with :active-view-transition-type(tab-next).

The CSS

The class names become selectors on the browser's view transition pseudo-elements:

::view-transition-group(.morph) { animation-duration: 400ms; }

::view-transition-old(.nav-forward) { --slide-offset: -60px; animation: 150ms ease-in both fade reverse, 400ms ease-in-out both slide reverse; }
::view-transition-new(.nav-forward) { --slide-offset: 60px; animation: 210ms ease-out 150ms both fade, 400ms ease-in-out both slide; }
::view-transition-old(.nav-back) { --slide-offset: 60px; animation: 150ms ease-in both fade reverse, 400ms ease-in-out both slide reverse; }
::view-transition-new(.nav-back) { --slide-offset: -60px; animation: 210ms ease-out 150ms both fade, 400ms ease-in-out both slide; }

/* Keep the header still and clicks working */
.site-header { view-transition-name: 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; }
::view-transition { pointer-events: none; }

@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes slide { from { translate: var(--slide-offset); } to { translate: 0; } }

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

The pointer-events: none rule matters: while a transition runs, the ::view-transition overlay otherwise swallows clicks.

What did my test show?

Every in-app pattern worked as documented. A forward click produced one transition typed nav-forward with the card-2 pair morphing, then a second untyped transition when the streamed details arrived. Tabs got tab-next and tab-prev. Reduced motion kept the transitions but finished them in under 50 ms. Browser back and forward produced no view transition.

I wrapped document.startViewTransition in a Puppeteer script and logged each call's types, the pseudo-elements it animated, and how long it took to finish:

Action View transitions started Types Finished in
Click card (grid to detail) 2: navigation, then Suspense reveal nav-forward, then none 431 ms, 427 ms
Click "← All cards" 1 nav-back 433 ms
Tab 0 to 2 1 tab-next 350 ms
Tab 2 to 1 1 tab-prev 334 ms
Browser back button 0 n/a n/a
Browser forward button 0 n/a n/a
Card click with prefers-reduced-motion: reduce 2 nav-forward, then none 49 ms, 29 ms

Both navigation transitions included ::view-transition-old(card-2) and ::view-transition-new(card-2), which is the shared pair.

Do view transitions run on the browser back button?

Not in my test. Using the browser's back or forward button in Next.js 16.4.2 started no view transition at all, even for the named morph. It was a client-side navigation (same document, URL and grid restored), and zero calls reached startViewTransition. Use in-app back links with transitionTypes={["nav-back"]} for animated returns.

The Next.js guide says browser back navigations "do not carry a transition type, so the directional slide does not play," and that "the shared element morph from Step 1 still applies." In headless Chrome 155 against a production build (next start), I could not reproduce the morph on back or forward. Treat back-button animation as unreliable until you have tested it in your own app and browsers.

Common React View Transition mistakes

The usual problems are updates that are not Transitions, wrappers placed in layouts, duplicate names on one page, a destination that suspends before the morph can pair, and expecting the back button to animate. Each fails silently: the UI changes instantly with no error.

Mistake Symptom Fix
Plain setState No animation Wrap the update in startTransition
Wrapper in layout.tsx Directional enter/exit never run Put it in each page.tsx
Same name twice on a page Broken or missing morph Build names from IDs
Destination suspends before the hero renders Content fades in instead of morphing Keep the shared element in the static or prefetched shell
default="none" without share Pair stops morphing (per Next.js docs) Add share="morph" or share="auto"
No pointer-events: none Clicks lost during animations ::view-transition { pointer-events: none; }
Relying on browser back Nothing animates In-app back links with transitionTypes
Ignoring reduced motion Slides for motion-sensitive users prefers-reduced-motion media query
Animating cached Suspense content Content that's already loaded still fades in update="auto" default="none" on the boundary

View Transitions vs Framer Motion (Motion)

Use View Transitions for route changes, shared elements across pages and loading reveals. The browser animates snapshots, so you add no animation library to your bundle. Keep Motion (formerly Framer Motion) for gestures, springs, drag, scroll-linked effects and animations that must respond while the user interacts.

Need View Transitions Motion
Morph between routes Built in (name) Needs layout animations plus shared state
Suspense loading reveal Built in Manual
Extra JavaScript None (browser API) A runtime library
Interruptible, gesture-driven No (snapshot animations; hit-testing skips named elements while they run) Yes
Springs and physics CSS easing only Yes
Browsers without support Instant update Animates

For the agent route, Next.js links a vercel-react-view-transitions skill you can install with npx skills add vercel-labs/agent-skills --skill vercel-react-view-transitions. My Agent Skills guide explains what that command installs. For the partial prerendering setup used in this demo, see my Next.js use cache guide.

Related: Next.js 16.4 also ships the experimental Rust React Compiler. I measured it against the Babel compiler and no compiler in React Compiler in Next.js 16.4: 20 counter clicks caused 0 row re-renders instead of 20,000.

Key takeaways

  • React View Transitions (ViewTransition and addTransitionType) are stable in React 19.3, released September 9, 2026, and work in Next.js 16.4 with no config flag.
  • Only Transitions animate: startTransition, Suspense reveals, useDeferredValue and Next.js navigations; a plain setState does not.
  • Give two elements the same name to morph them across routes, and keep the destination in a prefetched or static shell so they pair.
  • Tag links with transitionTypes and put the enter/exit wrapper in each page.tsx, not the layout.
  • In my test, browser back and forward buttons started no view transition at all, so use in-app back links for animated returns.
  • Add pointer-events: none on ::view-transition and a prefers-reduced-motion rule to every app.
  • Use View Transitions for page-level motion and keep Motion for gestures, springs and interactive animation.

FAQ

What are React View Transitions?

React View Transitions are the ViewTransition component and the addTransitionType function, stable since React 19.3 (September 9, 2026). They use the browser View Transition API to animate elements when they enter, exit, update or move between pages, while React chooses which elements take part and which CSS class each animation uses.

Do React View Transitions work in Next.js?

Yes. Next.js 16.4 ships React 19.3, and the Next.js docs say view transitions work in the App Router with no configuration. The old experimental.viewTransition flag is gone. Import ViewTransition from react, and every Link or router.push navigation animates because it is a Transition.

Why is my ViewTransition not animating?

Most often the update is not a Transition. A plain setState never animates; wrap it in startTransition. Other causes: the wrapper sits in a layout instead of page.tsx, two elements share a name on one page, the browser lacks support, or the navigation came from the browser back button.

How do I make a shared element transition in Next.js?

Wrap the element on both pages in a ViewTransition with the same unique name, such as card-2, and add share="morph" with default="none". React pairs them across the navigation and the browser animates size and position. The destination element must render in the same commit, so keep it in the prefetched shell.

What does addTransitionType do?

addTransitionType tags the current Transition with a reason, such as next or previous. Call it inside startTransition next to the state update, then map types to CSS classes in the enter, exit or update props. React also passes each type to the browser, so :active-view-transition-type() works in CSS.

Do view transitions work with the browser back button?

Not reliably. In my test with Next.js 16.4.2 and Chrome 155, the browser back and forward buttons started no view transition at all, even for named shared elements. Animated returns worked through in-app back links using transitionTypes={["nav-back"]}.

Which browsers support React View Transitions?

Next.js says the integration needs Chromium 125 or newer and recent Safari and Firefox versions, and some animations may behave differently in Safari. In browsers without support the app works normally and updates happen instantly, without animation.

Should I use View Transitions or Framer Motion?

Use View Transitions for route changes, cross-page shared elements and Suspense reveals; they add no animation library because the browser animates snapshots. Keep Motion (formerly Framer Motion) for gestures, springs, drag, scroll-linked effects and animations that must stay interactive while they run.


Originally published on fabwaseem.com.

Top comments (2)

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Clear and practical walkthrough. Pointing out that setState must be wrapped in startTransition to trigger an animation is definitely the #1 gotcha that trips people up when appears to do nothing. It’s great to see this API stabilized without needing experimental flags in Next.js 16.4, and testing the edge cases around browser back/forward navigation vs. regular Link clicks is an essential heads-up.

Collapse
 
fabwaseem profile image
Waseem Anjum •

Thanks! The startTransition one cost me some time too, because nothing errors, the UI just jumps. One nuance on back/forward: the Next.js guide says the shared-element morph should still play on browser back, but in my headless Chrome 155 run against next start I got zero view transitions on back or forward, so I'd test it in your own app before relying on it. In-app back links with transitionTypes={["nav-back"]} were the reliable path for me.