View Transitions in the Next.js App Router
How I made a blog thumbnail morph into the hero image on the next page, and the gotchas along the way.
- #nextjs
- #react
- #animation
- #accessibility
Route changes are jarring. You click a thumbnail, the whole page is thrown away, and a bigger version of the same image appears somewhere else. Nothing on screen tells you they are the same picture. On this site the cover image now slides and scales from the blog card straight into the hero on the post page, so it reads as one object moving rather than two images swapping.
That effect used to need a fair amount of code to track element positions across routes. In the Next.js App Router with React's ViewTransition component, it is almost free.
Turn it on
First, flip the flag in your Next config.
const nextConfig = {
experimental: {
viewTransition: true,
},
};
export default nextConfig;React's ViewTransition animations fire during transitions, and in Next a route navigation is a transition, so they activate on navigation automatically. No extra wiring.
Name the two elements
The trick is identity. You wrap the element on the old page and the matching element on the new page in a ViewTransition with the same name. React finds the pair, then animates between their size and position.
On the blog card:
import { ViewTransition } from "react";
import Image from "next/image";
<ViewTransition name={`cover-${post.slug}`}>
<Image src={post.cover.src} alt="" fill />
</ViewTransition>And on the post page, same name:
<ViewTransition name={`cover-${slug}`}>
<Image src={post.cover.src} alt="" width={1600} height={900} />
</ViewTransition>That is the whole morph. Click a card and the cover flies into the hero. Hit back and it flies home. Everything else on the page cross fades, which you also get for free once view transitions are on.
Keep the header still
The first time I tried this, the header cross faded with everything else and it flickered. During a transition you want one fixed reference point, so I gave the header a name and told it not to animate.
::view-transition-group(site-header) {
animation: none;
z-index: 100;
}
::view-transition-old(site-header) {
display: none;
}Now the content moves and the header stays put, which is exactly what your eye expects.
Respect reduced motion
A morph that slides across the screen is the sort of thing that makes some people feel unwell. Disabling it is one media query.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0s !important;
}
}The content still swaps, it just swaps instantly, which is the browser's default behaviour anyway.
Two things to know
The morph only fires between two pages that both carry the matching name. My home page lists recent posts without cover images, so navigating from there just cross fades. That is fine, it is the right signal for that link.
And Safari does not animate this the same way the others do. When there is no support the page still works, the transition simply happens without the animation. Nothing breaks, which is the correct way for a nice-to-have to fail.