Skip to main content

View Transitions in Next.js 16: Native Animations Without a Single Library

00:08:48:60

View Transitions in Next.js 16

Page transitions on the web have always been annoying to build. You pull in an animation library, wrestle with layout measurements, wire up exit animations that fight with React's render cycle, and hope nothing flickers. The View Transitions API gets rid of all that. It is built into the browser. It takes a screenshot of your UI before a change, takes another screenshot after, and animates between the two. The compositor thread handles the animation, so it stays at 60fps even when your JavaScript is doing other work.

Next.js 16 ships React 19.2, which includes a new <ViewTransition> component. That means you can now do all of this declaratively, without touching document.startViewTransition() yourself.

I have been using this in production on splitrate.app/changelog, where changelog entries animate with directional slides and the shared header stays pinned in place during navigation.

How it works in the browser

When document.startViewTransition() gets called, four things happen in order:

  1. The browser captures a rasterized screenshot of every element that has a view-transition-name (the "old" snapshot)
  2. Your DOM update callback runs
  3. The browser captures a second screenshot of every named element (the "new" snapshot)
  4. It builds a pseudo-element tree and cross-fades from old to new

The pseudo-element tree looks like this:

::view-transition
└─ ::view-transition-group(name)
   ├─ ::view-transition-image-pair(name)
   │  ├─ ::view-transition-old(name)   ← screenshot before
   │  └─ ::view-transition-new(name)   ← screenshot after

Since the browser is compositing rasterized images, the animation runs off the main thread. That is the big difference compared to libraries like Motion or GSAP, which measure and animate actual DOM elements on every frame.

Browser support

As of early 2026, single-document view transitions have about 89% global coverage:

  • Chrome / Edge 111+
  • Safari 18.0+
  • Firefox 144+
  • Opera 97+

Cross-document transitions (for MPAs) sit at around 82%. Since Next.js uses client-side navigation, the single-document API is the one you care about, and it works in all modern browsers.

Enabling it in Next.js 16

Next.js 16 includes React 19.2 with the <ViewTransition> component. To get deeper integration with the Next.js router, flip the experimental flag:

ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    viewTransition: true,
  },
};

export default nextConfig;

This flag tells Next.js to automatically wrap route navigations in startViewTransition. The team is also working on automatic transition types like navigation-forward and navigation-back. The <ViewTransition> component works even without the flag, but you lose the router-level integration.

The <ViewTransition> component

Instead of calling document.startViewTransition() yourself, you wrap elements in the component:

tsx
import { ViewTransition } from 'react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ViewTransition>
          {children}
        </ViewTransition>
      </body>
    </html>
  );
}

React handles the view-transition-name assignment for you. It applies names via inline styles, but only at the moment the transition fires. This avoids the annoying limitation where every participating element needs a globally unique name in CSS.

The component activates based on four triggers:

TriggerWhen it fires
enterThe <ViewTransition> is inserted during a startTransition
exitThe <ViewTransition> is removed during a startTransition
updateDOM mutations happen inside the boundary, or it changes size/position because of a sibling
shareA named <ViewTransition> unmounts and another with the same name mounts in the same transition

One thing that trips people up: <ViewTransition> only activates during a React Transition. If you call setState directly, nothing animates. You need to use startTransition, useTransition, or trigger it through a Suspense reveal.

Shared element transitions

Give two <ViewTransition> components the same name and React will create a shared element transition. The browser morphs one element into the other:

tsx
import { ViewTransition, startTransition, useState } from 'react';

function ThumbnailView({ item, onSelect }: { item: Item; onSelect: () => void }) {
  return (
    <ViewTransition name={`item-${item.id}`} share="morph">
      <img
        src={item.thumbnail}
        onClick={() => startTransition(() => onSelect())}
      />
    </ViewTransition>
  );
}

function DetailView({ item, onBack }: { item: Item; onBack: () => void }) {
  return (
    <ViewTransition name={`item-${item.id}`} share="morph">
      <img src={item.fullImage} />
    </ViewTransition>
  );
}

When ThumbnailView unmounts and DetailView mounts during the same transition, the browser captures the thumbnail position and size, then animates it to the detail image position and size. No FLIP calculations, no measuring refs, no requestAnimationFrame loops.

One rule to keep in mind: the name must be unique across your entire app at any given time. Only one component with that name should be mounted. React will warn you in development if you have duplicates.

Customizing animations with CSS

The default animation is a cross-fade. You can change it by passing a class name to the activation props and then styling the view transition pseudo-elements:

tsx
<ViewTransition
  enter="slide-up"
  exit="fade-out"
  update="crossfade"
  share="morph"
>
  <div>{content}</div>
</ViewTransition>
css
::view-transition-new(.slide-up) {
  animation: slideUp 300ms ease-out;
}

::view-transition-old(.fade-out) {
  animation: fadeOut 200ms ease-in;
}

::view-transition-old(.morph),
::view-transition-new(.morph) {
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

@keyframes slideUp {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
}

@keyframes fadeOut {
  to {
    opacity: 0;
    transform: scale(0.95);
  }
}

The selector pattern uses ::view-transition-old(.class-name) and ::view-transition-new(.class-name). React uses view-transition-class from the Level 2 spec under the hood instead of name-based selectors. Classes can be reused across multiple elements, names cannot.

Directional transitions with addTransitionType

If you want forward navigation to slide left and back navigation to slide right, you can use addTransitionType. Call it inside startTransition before the state update:

tsx
import { startTransition, addTransitionType } from 'react';
import { useRouter } from 'next/navigation';

function useAnimatedNavigation() {
  const router = useRouter();

  return {
    push: (href: string) => {
      startTransition(() => {
        addTransitionType('navigation-forward');
        router.push(href);
      });
    },
    back: () => {
      startTransition(() => {
        addTransitionType('navigation-back');
        window.history.back();
      });
    },
  };
}

Then in your layout, map those types to CSS classes:

tsx
<ViewTransition
  default={{
    'navigation-forward': 'slide-left',
    'navigation-back': 'slide-right',
    default: 'crossfade',
  }}
>
  {children}
</ViewTransition>
css
::view-transition-old(.slide-left) {
  animation: 300ms ease-in both slideOutLeft;
}
::view-transition-new(.slide-left) {
  animation: 300ms ease-out both slideInRight;
}

::view-transition-old(.slide-right) {
  animation: 300ms ease-in both slideOutRight;
}
::view-transition-new(.slide-right) {
  animation: 300ms ease-out both slideInLeft;
}

@keyframes slideOutLeft {
  to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slideInRight {
  from { transform: translateX(100%); opacity: 0; }
}
@keyframes slideOutRight {
  to { transform: translateX(100%); opacity: 0; }
}
@keyframes slideInLeft {
  from { transform: translateX(-100%); opacity: 0; }
}

This is the pattern I used on splitrate.app/changelog. Each changelog entry slides in from the direction you are navigating, while the header and shared elements morph in place.

Animating Suspense boundaries

You can animate the moment a suspended component finishes loading. Wrap the Suspense boundary in a <ViewTransition>:

tsx
<ViewTransition>
  <Suspense fallback={<Skeleton />}>
    <DataComponent />
  </Suspense>
</ViewTransition>

When the data loads, <Skeleton> gets replaced by <DataComponent>, and the <ViewTransition> wrapper treats that as an "update". It cross-fades between the two. React also waits up to 500ms for fonts to load before kicking off the animation, and waits for images inside a <ViewTransition> to load too. This prevents that ugly flash where content pops in unstyled.

If you want separate enter and exit animations instead of a cross-fade, put the <ViewTransition> inside the boundary:

tsx
<Suspense fallback={<ViewTransition exit="fade-out"><Skeleton /></ViewTransition>}>
  <ViewTransition enter="slide-up"><DataComponent /></ViewTransition>
</Suspense>

Animating list reorders

Wrap each item in its own <ViewTransition> and the browser will slide them to their new positions:

tsx
function VideoGrid({ videos }: { videos: Video[] }) {
  return (
    <div className="grid">
      {videos.map(video => (
        <ViewTransition key={video.id}>
          <VideoCard video={video} />
        </ViewTransition>
      ))}
    </div>
  );
}

When the list order changes inside a startTransition, React sees that each boundary moved position and triggers the "update" animation.

One gotcha here: <ViewTransition> must be the outermost element. If you wrap it in a <div> first, the individual items will not animate. The parent would just cross-fade as a whole.

Opting out of animations

If you wrap a large subtree (like an entire page) and want to animate theme changes but not every random child update, pass "none" to the update prop:

tsx
<ViewTransition>
  <div className={theme}>
    <ViewTransition update="none">
      {children}
    </ViewTransition>
  </div>
</ViewTransition>

Only the theme wrapper animates. Child updates stay inert unless they have their own <ViewTransition> boundaries.

Respecting reduced motion

React does not disable animations for users who prefer reduced motion. You need to handle that yourself:

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
  }
}

Things to watch out for

Only works with Transitions. A plain setState call will not trigger <ViewTransition>. You need startTransition, useTransition, or a Suspense reveal.

Placement matters. The <ViewTransition> must come before any DOM node. This will not work:

tsx
function Broken() {
  return (
    <div>
      <ViewTransition>Content</ViewTransition>
    </div>
  );
}

Back button skips the animation. If your router still uses the legacy popstate event, React skips the animation to preserve scroll and form restoration. Moving to the Navigation API fixes that.

It animates images, not elements. View transitions work with rasterized screenshots. Text does not reflow mid-animation, it morphs as a flat image. For most UI that actually looks better, but if you need each element to animate its layout individually, you probably still want a library like Motion.

Still experimental in Next.js. The viewTransition config flag is experimental as of Next.js 16.1. The <ViewTransition> component in React is on the Canary channel. The API could still change, but the underlying browser API is stable and shipped in all major browsers.

Putting it all together

Here is a complete Next.js 16 layout with view transitions for page navigations and a shared header:

tsx
import { ViewTransition } from 'react';
import { Navigation } from '@/components/Navigation';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ViewTransition name="header" update="none">
          <Navigation />
        </ViewTransition>
        <ViewTransition
          default={{
            'navigation-forward': 'slide-forward',
            'navigation-back': 'slide-back',
            default: 'crossfade',
          }}
        >
          <main>{children}</main>
        </ViewTransition>
      </body>
    </html>
  );
}

The header stays in place because its update is opted out. The main content slides in the direction you are navigating. Add the CSS keyframes from earlier and you have a fully animated Next.js app with zero animation dependencies.