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.

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:
- We feature-detect
startViewTransition. Falling back to a normal<Link>click is fine — the page still works, it just doesn't animate. - We respect modifier keys so cmd-click still opens in a new tab.
- We pass the navigation through
startTransitionso 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 withrouter.prefetch(href)ononMouseEnter. - 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-nameon 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.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Font Loading in 2026: Why next/font Isn't Always the Answer
next/font is great until it isn't. A practical breakdown of when to use it, when to ship raw @font-face, and how to stop fonts from wrecking your LCP and CLS scores.

View Transitions in the App Router: The Good, the Janky, and the Layout Shifts
Cross-document view transitions finally landed in stable browsers and Next.js shipped a primitive for them. Here's what actually works in production, what still tears, and how to keep CLS honest.

Middleware Is Not a Router: What We Learned Rebuilding Auth on the Edge
Next.js middleware looks like the perfect place for auth. Then your bundle balloons, your Edge runtime chokes on a Node API, and your login redirects loop. Here's how we untangled it.
