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.

We shipped view transitions on a content-heavy marketing site last quarter. It looked stunning in Chrome on a MacBook and embarrassing on a mid-range Android. That gap — between the demo and the device your users actually hold — is the whole story of the View Transition API right now.
This is a field report: what the API gives you in the Next.js App Router today, where the seams still show, and the patterns we keep returning to.
What the API actually does (and what it doesn't)
The browser-native View Transition API takes a snapshot of the old DOM, lets you mutate to the new DOM, then crossfades between two pseudo-element layers (::view-transition-old(root) and ::view-transition-new(root)). If you tag elements with view-transition-name, the browser animates them as paired "named" transitions instead of getting swept up in the root crossfade.
That's it. It is not a router. It is not a state machine. It does not know about React. It is a controlled snapshot-and-tween, and everything you've read about "morphing" pages is built on those two pseudo-elements.
A few things to internalize before you start:
- The snapshot is paint-based, not DOM-based. The old element doesn't exist during the transition — only an image of it.
- Named transitions must be unique per document. Two elements with the same
view-transition-namein the same frame will abort the transition. - The transition runs on the compositor, but the DOM swap underneath still has to happen. If your new page is slow to render, the user sees a frozen snapshot. That's the jank.
The Next.js piece: <ViewTransition> and what it wraps
React 19 introduced an experimental <ViewTransition> component, and Next.js exposes it through its experimental flag. The component is a thin scheduler — it tells React to wrap the upcoming commit inside document.startViewTransition() so the snapshot is taken before your tree updates.
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
viewTransition: true,
},
};
export default config;
With the flag on, you can wrap navigations:
// app/layout.tsx
import { unstable_ViewTransition as ViewTransition } from 'react';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ViewTransition>{children}</ViewTransition>
</body>
</html>
);
}
That alone gives you the default root crossfade between routes. It looks fine. It also looks identical to every other site that did this in week one, which is part of why we don't ship it like this.
Naming pairs across routes
The interesting work is pairing elements. On a product index, the card thumbnail should appear to grow into the hero image on the product detail page. Both elements need the same view-transition-name, and that name needs to be unique in each document.
// app/products/[id]/page.tsx
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<article>
<img
src={product.heroUrl}
alt={product.name}
style={{ viewTransitionName: `product-${id}` }}
/>
<h1>{product.name}</h1>
</article>
);
}
On the index, the card uses the same template:
<Link href={`/products/${product.id}`}>
<img
src={product.thumbUrl}
alt={product.name}
style={{ viewTransitionName: `product-${product.id}` }}
/>
</Link>
Key rule: only the element being clicked should hold that name during the click. If every card on the index has product-123, product-124, ... that's fine — they're all unique. But if you accidentally render the same name twice (a duplicated list, a leaked prop), the browser silently skips the transition. We've lost hours to this. Add a dev-only assertion that scans document.querySelectorAll('[style*="view-transition-name"]') for duplicates if you're doing anything dynamic.
Where it gets janky
The new page hasn't rendered yet
The View Transition API takes the old snapshot synchronously, then waits for you to update the DOM, then animates. In a Next.js App Router navigation, "update the DOM" can mean waiting for a server component to stream, a Suspense boundary to resolve, and images to decode. The browser will hold the old snapshot frozen the entire time.
On a fast desktop with a warm cache, you don't notice. On a cold 4G connection, the page looks broken for 600ms before anything moves.
The mitigations we use:
- Prefetch aggressively on the routes you transition into.
<Link prefetch>is already the default, but check the Network tab — if the RSC payload isn't there before the click, your transition will stall. - Hoist the matched element above Suspense. If the hero image is inside a
<Suspense>boundary that's still loading, the named pair breaks because the new element doesn't exist when the snapshot diff runs. Put paired elements in the synchronous part of the new route. - Set explicit dimensions. The old snapshot has fixed pixel dimensions. If the new element renders at a different size because an image hasn't loaded, you get a pop at the end of the animation.
CLS lies to you
The Core Web Vitals CLS metric is computed from layout shifts, and view transitions are not counted as layout shifts — they happen on the compositor on a pseudo-element layer. Good news, right?
Not really. The shift that happens after the transition completes — when an image finally decodes and your real DOM reflows — absolutely counts. We had a site where Lighthouse reported a CLS of 0.02 and field data from CrUX showed 0.18. The difference was a hero image that swapped to its real intrinsic size half a second after the transition ended.
The fix:
<img
src={product.heroUrl}
alt={product.name}
width={1200}
height={800}
style={{
viewTransitionName: `product-${id}`,
aspectRatio: '3 / 2',
width: '100%',
height: 'auto',
}}
/>
Reserve the space. Always. The view transition makes a bad reservation look like a good animation, which is worse than a normal jank because you can't see it in DevTools without throttling.
Customizing the animation without rebuilding the wheel
The default crossfade is 250ms ease. You can override per-name from CSS:
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 200ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
::view-transition-group(product-hero) {
animation-duration: 320ms;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root),
::view-transition-group(*) {
animation: none !important;
}
}
That prefers-reduced-motion block is not optional. The View Transition API does not respect the user's motion preference for you. If you ship without that media query, you are shipping an accessibility bug. We've seen audits flag this and it's a fair call.
Opting out per navigation
Not every navigation should animate. Going from a checkout step to an error state should be instant. You can skip a transition by calling transition.skipTransition() on the returned object — but in the React/Next wrapper you don't have direct access. The pattern we use is a route-level flag:
'use client';
import { useRouter } from 'next/navigation';
export function PayButton() {
const router = useRouter();
function handleClick() {
// Disable transitions for this navigation by removing the
// view-transition-name from anything that might pair.
document.documentElement.dataset.skipTransition = 'true';
router.push('/checkout/confirm');
}
return <button onClick={handleClick}>Pay</button>;
}
Then in CSS:
:root[data-skip-transition='true'] * {
view-transition-name: none !important;
}
Ugly, but reliable. Clear the flag in a useEffect on the destination page.
Browser support, gracefully
As of 2026, cross-document view transitions are stable in Chromium and partially shipped in Safari. Firefox is still behind a flag. The React component degrades to a regular commit when the browser lacks support — no transition, no error. That's the right default. Don't write feature-detection branches in your component tree; let the browser no-op.
The one exception: if your animation depends on a paired element being in a specific position, test what the page looks like without the transition. A hero that's perfectly aligned mid-tween might be jarring when it just appears.
What we'd do
If we were starting fresh on a content site today, we'd turn on the experimental flag, wire a single root <ViewTransition> in the layout, and ship the default crossfade behind a feature flag for 10% of traffic. We'd measure INP and CLS from CrUX, not Lighthouse, for two weeks before naming a single element.
Then we'd pick the one navigation that benefits most — usually index → detail — and pair exactly two elements: the hero image and the title. No more. Every additional named pair multiplies the failure modes (duplicate names, Suspense boundaries, late-loading assets) and most users won't notice the difference between two paired elements and seven.
If you're building something more interactive — a dashboard, a product configurator — view transitions are probably the wrong tool. Reach for a real animation library that understands state, or our web development team can talk through what fits. The View Transition API is a sharp tool for a narrow job. Use it there and it sings.
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.

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.

Cache Tags in Next.js: How We Stopped Nuking the Entire CDN on Every Publish
A war story about how `revalidateTag` and a disciplined tagging scheme replaced our nightly full-cache purge — and the four gotchas we hit getting there in production.
