All articles
Web DevelopmentMay 24, 2026 6 min read

View Transitions in Next.js: The Animation API That Finally Works

The View Transitions API graduated from Chrome-only experiment to a real cross-browser feature. Here's how to wire it into Next.js App Router without breaking streaming, hydration, or your sanity.

View Transitions in Next.js: The Animation API That Finally Works

For years, page transitions on the web meant either accepting the jarring flash of a full reload or building a single-page app and inheriting its problems. The View Transitions API changes that, and as of 2026 it's stable enough in Chromium and Safari (with a usable Firefox implementation behind a flag in most builds) that we ship it in production. The catch: making it cooperate with Next.js App Router, streaming, and Server Components takes more care than the demos suggest.

This is a field guide based on the patterns we've kept after shipping View Transitions on three client apps — one e-commerce, one dashboard, one content site.

What the View Transitions API actually does

The API gives the browser two snapshots — the DOM before a change, and the DOM after — and animates between them. You don't write keyframes for every element. You mark elements with a view-transition-name and the browser figures out the morph.

There are two flavours:

  • Same-document transitions: document.startViewTransition(() => updateDOM()). Works inside an SPA-style route change.
  • Cross-document transitions: the browser handles the snapshot across full navigations, opted in via CSS @view-transition { navigation: auto; }.

Next.js App Router uses client-side navigation by default, so for most apps you want same-document transitions wired into the router. Cross-document is useful for MPA-style content sites or when you want a transition on the very first navigation from an external referrer — and that's a different conversation.

Why this matters more than CSS transitions

CSS transitions animate properties on a single element. View Transitions animate the visual delta between two DOM states. That includes elements that didn't exist before, elements that are about to be removed, and elements that moved across the tree. A product card expanding into a product detail page isn't a CSS transition — it's a layout reconciliation. View Transitions handle it natively.

Wiring it into the App Router

Next.js doesn't expose a transition hook on <Link> directly, but useRouter from next/navigation combined with React 19's useTransition gives you a clean integration point.

'use client';

import { useRouter } from 'next/navigation';
import { useTransition, type MouseEvent, type ReactNode } from 'react';
import Link from 'next/link';

type Props = {
  href: string;
  children: ReactNode;
  className?: string;
};

export function TransitionLink({ href, children, className }: Props) {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
    if (
      e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ||
      !('startViewTransition' in document)
    ) {
      return; // let the browser handle it natively
    }

    e.preventDefault();

    document.startViewTransition(() => {
      // React 19: flush the navigation inside the transition snapshot
      startTransition(() => {
        router.push(href);
      });
    });
  };

  return (
    <Link href={href} onClick={onClick} className={className} data-pending={isPending}>
      {children}
    </Link>
  );
}

A few things worth noting:

  1. We feature-detect startViewTransition. Falling back to a normal <Link> click is fine — the page still works, it just doesn't animate.
  2. We respect modifier keys so cmd-click still opens in a new tab.
  3. We pass the navigation through startTransition so React doesn't tear the UI while the snapshot is being captured.

The streaming problem

This is the gotcha that costs people a day of debugging. App Router streams HTML. If you call router.push() and the destination route has a slow Server Component, the browser captures the old snapshot, then sits with the page frozen while the new RSC payload streams in. The transition completes after the network round-trip, which feels worse than no transition at all.

Two fixes:

  • Prefetch aggressively. <Link prefetch> is on by default, but only when the link enters the viewport. For above-the-fold critical paths, hover-prefetch works better. Wire it up with router.prefetch(href) on onMouseEnter.
  • Use a loading boundary that participates in the transition. If the destination has a loading.tsx, the skeleton becomes the "after" state of the transition. As long as the skeleton roughly matches the final layout, the morph still looks coherent.

Naming elements that should morph

The magic of View Transitions comes from view-transition-name. Two elements on different pages with the same name get morphed into each other.

.product-card-image {
  view-transition-name: product-image;
}

Problem: if two product cards are visible on a list page and you give them all the same name, the browser throws a warning and disables the transition. Each name must be unique within a snapshot.

The pattern we use is to assign the name dynamically based on the active item:

// On the list page
<Link href={`/products/${id}`} onClick={handleClick}>
  <img
    src={imageUrl}
    style={{ viewTransitionName: activeId === id ? 'product-image' : undefined }}
    alt={name}
  />
</Link>

You set activeId in the click handler before calling startViewTransition, so by the time the browser snapshots, only the clicked card has the name. On the detail page, the hero image always has view-transition-name: product-image. The morph happens between exactly two elements.

Per-element timing with CSS

The transition runs against pseudo-elements the browser generates: ::view-transition-old(name) and ::view-transition-new(name). You can target them in CSS to customise duration, easing, or animation:

::view-transition-old(product-image),
::view-transition-new(product-image) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}

::view-transition-group(root) {
  animation-duration: 180ms;
}

The root group covers everything that isn't explicitly named. Keeping it short prevents the rest of the page from feeling sluggish while the hero element does its slower morph.

Accessibility — don't skip this

View Transitions are visual. For users who've set prefers-reduced-motion, the right answer is usually to skip the animation but keep the DOM update.

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

This preserves the snapshot mechanism but zeroes out the motion. Users still get an instant page swap, no vestibular trigger.

Also: don't animate focus rings. If a user is keyboard-navigating, focus needs to land on the new page immediately. Run focus management after the transition's finished promise resolves, not during.

const transition = document.startViewTransition(() => router.push(href));
transition.finished.then(() => {
  document.querySelector<HTMLElement>('h1')?.focus();
});

Performance: what we actually measure

The API is GPU-accelerated, but it isn't free. Snapshots cost memory proportional to the rendered area, and complex pages with hundreds of named elements cause measurable jank on mid-range Android devices.

In our experience:

  • Keep named elements under ~20 per transition. More than that and the browser spends real time generating pseudo-elements.
  • Don't put view-transition-name on elements inside long lists. Only name what's about to morph.
  • INP can improve with transitions because the perceived response feels instant — the browser commits the snapshot quickly even if the new content streams in afterward. But if you're already failing LCP on the destination, a transition makes it more obvious, not less.

Run Lighthouse on the destination page in isolation first. If LCP is broken, fix that before adding animation polish.

The hydration mismatch trap

If you set view-transition-name based on client-only state (like activeId), make sure it's not in the initial render. Setting it during render on the server with a value that only the client knows will cause a hydration warning. Either set it in an effect, set it imperatively in the click handler before calling startViewTransition, or gate the attribute behind a useEffect-set boolean.

When not to use it

There's a temptation to wrap every navigation in a transition. Don't.

  • Dashboards with dense data tables: a morphing table looks weird and reads as a bug. Use targeted transitions on the panel level instead.
  • Forms mid-submission: never start a view transition during a server action round-trip. The optimistic update and the snapshot fight each other.
  • Modals and drawers: CSS transitions are still better here. View Transitions excel at cross-route morphs, not at in-place UI state.

Where we'd start

If you've never shipped a view transition, pick one high-traffic flow — usually list-to-detail on an e-commerce product page or a card-to-article view on a content site. Build the TransitionLink wrapper, name exactly one element (the hero image), and test on a real Android device before you celebrate. From there, expand carefully: each named element is a contract between two routes, and contracts get expensive once you have dozens of them.

If you want a hand auditing your App Router setup or planning a design-system-wide animation pass, that's the kind of thing our web team does week-in, week-out.

#Next.js#React 19#Performance#Animation#App Router

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project