next.jsanimayionscss and layouts

View Transitions in Next.js: Page Animations Without an Animation Library

View Transitions in Next.js: Page Animations Without an Animation Library

In the last article I showed that INP measures the gap between a click and the frame that reflects it, and that one long task on the main thread can ruin that gap no matter how cheap the animation itself is. Page transitions are the extreme version of that problem, because they ask the main thread to do its most expensive work — rendering a whole new page — and to run a smooth 60fps animation, at the same instant.

It's worth being precise about what that work actually was, because the name for it is thirty seconds of history that explains the entire API that replaced it.

What the libraries were really doing: FLIP

In 2015 Paul Lewis described a technique he called FLIP — First, Last, Invert, Play. You can't cheaply animate an element from one layout position to another, because top and left force a layout recalculation on every frame. So you cheat. You record the element's position First, let the DOM jump instantly to its Last state, then Invert that jump with a transform that makes the element look like it never moved, and finally Play the transform back to zero. The user sees a smooth glide. The browser only ever animated a transform.

Every shared-layout animation you've seen — Framer Motion's layoutId, GSAP's Flip plugin, every "magic move" in a design tool — is FLIP. And FLIP has a structural cost: something has to keep both the old and the new element alive at once, call getBoundingClientRect() on both, and do it synchronously, in JavaScript, in the middle of a route change. In an App Router app that means keeping the outgoing page mounted while the incoming one renders, coordinating exit animations through AnimatePresence, and only then unmounting the old tree.

The View Transitions API takes that trick away from you and gives it to the browser. Instead of keeping two live DOM trees around and measuring them, the browser photographs the old state, lets the DOM update however it likes, photographs the new state, and animates between the two pictures — on the compositor, exactly like the transform and opacity from the microinteractions article.

The sequence, and the tree it builds

The core of the API is one call. Wrap a DOM update in document.startViewTransition() and the browser runs a fixed sequence: freeze rendering, capture the current page, run your callback, capture the result, and build a tree of pseudo-elements on top of the document.

javascript

function navigate(update) {
if (!document.startViewTransition) {
update(); // no support: just update, no animation
return;
}
document.startViewTransition(() => update());
}

That pseudo-element tree is worth knowing by name, because it's the difference between customizing a transition confidently and guessing at selectors:

css

::view-transition /* the overlay covering the whole page */
::view-transition-group(name) /* animates size and position */
::view-transition-image-pair(name)
::view-transition-old(name) /* the "before" snapshot */
::view-transition-new(name) /* the "after" snapshot */

The split matters. The group is what moves and resizes — so animation-duration belongs there. The old/new pair is what cross-fades — so opacity and filter effects belong there. Setting a duration on ::view-transition-new and wondering why the element still snaps into position is one of the most common first mistakes with this API.

The part that surprises everyone: a snapshot is a photograph, not an element

This is the single most important mental model, and it's the thing the API's simplicity hides. ::view-transition-old is not your old element rendered smaller. It is a static image of it, captured once, at one instant.

Everything follows from that. A <video> inside a transition freezes on one frame for the duration. A spinner stops spinning. Text inside the snapshot can't be selected and isn't reachable by a screen reader — the real DOM underneath has already been replaced, and what you're looking at is a picture of something that no longer exists. A GIF stops. A CSS animation inside the captured element pauses mid-way.

None of that matters at 200ms. All of it becomes obvious and slightly broken at 1000ms. That's the real reason to keep transitions short, and it's a much better reason than "long animations feel slow" — at some duration, the user stops seeing a transition and starts seeing a frozen screenshot of your app.

It also explains the aspect-ratio trap. If the old element is a 1:1 thumbnail and the new one is a 16:9 hero, the browser is interpolating between two images of different shapes, and you get a visible squash mid-flight. The fix is to give both elements the same aspect ratio, or to override object-fit on the old/new pseudo-elements so the snapshots crop rather than stretch.

Naming: the whole contract

By default you get a crossfade of the entire page. The interesting behavior starts when you give an element a view-transition-name. A named element is lifted out of the page snapshot and gets its own group, which the browser interpolates by position and size — not just opacity. Same name on both sides means the browser moves and scales one into the other. That's FLIP, done for you, in CSS.

css

.card-image {
view-transition-name: hero;
}
::view-transition-group(hero) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}

The one hard rule: at any given moment, a view-transition-name must be unique in the document. Two elements sharing a name make the entire transition fail — not just that element, the whole thing — and it fails silently, with the page simply snapping like it always did. With a list of cards this is the default outcome, since every card wants the same name. The fix is to derive the name from the record's id, which is exactly what the React API below does.

React 19.2 and Next.js 16: one component

Doing this by hand in a framework was always awkward, because the moment of the DOM update belongs to React, not to you. React solves it with the <ViewTransition> component, and Next.js has used it since version 16 with no configuration — the App Router runs on React canary builds that already include it, and route navigations are React Transitions, so the animations activate on navigation automatically.

tsx

import { ViewTransition } from "react";
import Image from "next/image";
import Link from "next/link";
export function ProjectCard({ project }) {
return (
<Link href={"/projects/" + project.slug}>
<ViewTransition name={"project-" + project.slug}>
<Image src={project.cover} alt={project.title} />
</ViewTransition>
</Link>
);
}

On the detail page you wrap the large image in a <ViewTransition> with the same name, and React pairs them up and morphs the thumbnail into the hero. Going back reverses it. Note what's absent: no exit animations, no AnimatePresence, no measuring, no layout ids. The name is the entire contract.

One detail from the Next.js docs deserves repeating because it fails silently: share and default="none" have to travel together. default="none" stops a named element from cross-fading during every unrelated transition on the page — but with default="none" and no share prop, the pair quietly stops morphing altogether.

There's also a timing condition that's easy to misread as a bug. The morph only plays when the destination renders in the same commit as the navigation, which is the case for prefetched pages. If the destination suspends into a loading fallback first, no pair forms, and the content plays its enter animation instead when it finally arrives.

Direction: telling the user which way they went

A morph tells the user what moved. Direction tells them where they are — deeper, or back. Next.js 16.2 added transitionTypes to <Link> (and to router.push()), which tags a navigation so <ViewTransition> can map each tag to a different animation.

tsx

<Link href="/projects/runvalis" transitionTypes={["nav-forward"]}>
Runvalis
</Link>
<ViewTransition
enter={{ "nav-forward": "slide-in", "nav-back": "slide-out", default: "none" }}
exit={{ "nav-forward": "slide-out", "nav-back": "slide-in", default: "none" }}
default="none"
>
<main>{children}</main>
</ViewTransition>

Two things here are easy to get wrong. First, the wrapper has to live in each page.tsx, not in the layout — layouts persist across navigations, so their content never enters or exits and the animation simply never fires. Second, the browser's own back button and swipe gestures carry no transition type, so the directional slide won't play for them; only the shared-element morph will. That asymmetry is deliberate, but it does mean you should test the back button separately from your own back links.

The version with no framework at all

Worth knowing even if you never leave Next.js: the same mechanism exists for plain multi-page sites, with no JavaScript whatsoever. Two same-origin documents that both opt in get a transition on ordinary navigation:

css

@view-transition {
navigation: auto;
}

That's the whole setup. view-transition-name then works across documents — a thumbnail on one HTML page morphs into the hero on another. It shipped in Chromium first, and it's the strongest argument that this API isn't a React feature that happens to use CSS, but a browser feature that React happens to expose nicely.

Pitfalls and best practices

  • Treat it as progressive enhancement, always. Where the browser lacks support, nothing breaks — the navigation just happens instantly, the way it always did. That's the correct failure mode. Don't reach for a polyfill to force the animation everywhere; you'd be reintroducing exactly the main-thread cost the API exists to remove.
  • Debug it in slow motion. The pseudo-element tree only exists during the transition, which makes it nearly impossible to inspect at 300ms. Chrome DevTools' Animations panel lets you drop playback to 25% or 10% — at that speed you can actually see which group is moving, catch a snapshot that's squashing, and spot an element that's crossfading when you meant it to morph.
  • Respect prefers-reduced-motion. Directional slides simulate movement across the entire viewport, which is the textbook trigger for vestibular discomfort. At minimum, zero out animation-duration and animation-delay for ::view-transition-old(*), ::view-transition-new(*) and ::view-transition-group(*) inside a reduce query — the content then swaps instantly, which is just the browser's default behavior. Note that this is not the same as removing feedback: the navigation still happens, it just doesn't travel.
  • The overlay eats clicks by default. While a transition runs, ::view-transition sits above the page and captures pointer events, so anything clicked mid-animation is lost. Set ::view-transition { pointer-events: none } to let them through to the live page. Hit-testing still skips named participants for the duration, so don't name elements that users click in rapid succession.
  • Snapshots cost memory. Every named element becomes its own texture, and photographing a very long page isn't free. Name what carries meaning, not everything that moves.
  • Anchor the header. If the whole viewport slides, the user loses their fixed reference point. Give the header its own name, set animation: none on its group, and display: none on its old snapshot to avoid a flash of two headers — then only the content moves.
  • A view transition is invisible to assistive technology. It moves no focus and announces nothing. Everything from the accessible components article still applies: after a navigation, focus management and announcements are your job. The animation is decoration on top of a navigation that must already work without it.

Conclusion

The INP article ended on the idea that perceived performance is only as strong as its weakest link, and that the weakest link is usually your own JavaScript. View Transitions are the same argument applied to navigation: instead of running FLIP by hand — two mounted trees, synchronous measurement, coordinated exit animations, all on the main thread during a route change — you declare what connects two states and let the browser draw the connection from two photographs. The price is a small vocabulary (name, share, enter, exit, transitionTypes), one hard rule about unique names, and remembering that what the user is watching mid-transition is a picture, not your app. Against the cost of keeping an animation library synchronized with a router, that's an unusually good trade.