Click a card in the post list, and its heading slides straight into the heading of the detail page. Native apps do this all the time, and I wanted it on this site too. The implementation itself was a few dozen lines, but I kept running into cases where nothing failed with an error or a warning, and most of my time went into finding out why.
This post records how I built shared-element transitions with Next.js 16 (App Router) and React's <ViewTransition>, together with the numbers I actually measured. I hope it saves someone attempting the same thing from falling into the same holes.
Setup and goals#
Environment#
| Item | Version / setting |
|---|---|
| Next.js | 16.3 (App Router, every route prerendered) |
| React | 19.2 (the App Router runs on the React bundled with Next.js) |
| Styling | Tailwind CSS v4 + design tokens as CSS custom properties |
| Measurement | getBoundingClientRect() read with Playwright (Chromium) |
Goals#
A post card in the list shows a category badge, a heading and tags. The detail page has the same three. The starting point was to tell the browser that these are "the same things" and let it interpolate their position and size when the page changes.
- The card's heading moves and grows into the detail page's
h1 - The category and tag badges move to their matching places
- Everything else switches with a page-wide crossfade
- Nothing moves at all when
prefers-reduced-motionis on
How it works, roughly#
The View Transitions API takes snapshots before and after the DOM changes, layers the two as a tree of pseudo-elements (::view-transition-old / ::view-transition-new) and interpolates between them. Elements that share a view-transition-name have their old and new snapshots grouped under one ::view-transition-group, whose rectangle animates in position and size.
React 19's <ViewTransition> ties that naming and the call to document.startViewTransition() to React transitions (startTransition). The Next.js App Router already wraps router navigations in startTransition, so just adding the wrapper puts page navigations on this API.
import { ViewTransition } from "react";
export default function LocaleLayout({ children }: { children: React.ReactNode }) {
return (
<main>
{/* An unnamed wrapper becomes a crossfade shared by every page */}
<ViewTransition>{children}</ViewTransition>
</main>
);
}For the elements you want to share, give them the same name on both the list and the detail page.
export function PostCard({ post }: { post: Post }) {
return (
<ViewTransition name={`post-title-${post.slug}`}>
<h2 className="w-fit font-bold text-xl">{post.title}</h2>
</ViewTransition>
);
}So far this is straight from the official docs. The trouble started after this.
Pitfall 1: name collisions fail silently#
If you name tags the obvious way, post-tag-${tag}, then as soon as two posts with the same tag sit in the list, one snapshot contains the same name twice. A view-transition-name must be unique within a snapshot; when names collide, the transition is skipped.
The nasty part is that the screen just falls back to a plain switch, and in my testing React did not warn about it either. All you are left with is "it doesn't move, for some reason."
The fix is simple: scope the name with the slug of the entity that owns it.
- <ViewTransition name={`post-tag-${tag}`}>
+ <ViewTransition name={`post-tag-${post.slug}-${tag}`}>Since then this site fixes the naming rule as {domain}-{element}-{slug}[-{subkey}] and writes it down in the design system docs. Without a rule, the next person (future me) to add a shared element will step into the same trap.
Pitfall 2: it's the text's box that gets interpolated#
Once names are in place the heading moves, but it can turn into a blurry double image in flight, as if a short heading were stretched sideways and then shrunk.
The cause is that what gets interpolated is not the visible width of the text but the rectangle of the element's box. A heading is a block element, so even short text fills the full width of its parent.
These are the box widths of the heading in the two images above (a Japanese post titled "We launched riku-mono.me"), measured at a 1280px-wide viewport.
| Case | Box width in list | Box width in detail |
|---|---|---|
No w-fit (still block) | 546px | 984px |
w-fit on both sides | 320px | 528px |
Without w-fit, the rectangle being interpolated goes from 546px to 984px and is stretched far sideways, regardless of how much the text itself grows. For left-aligned text, adding w-fit does not move where the text is drawn. Without changing the look at all, it makes the rectangle the interpolation starts from hug the text.
Badges (inline-flex) already have boxes that hug their text, so they moved cleanly with no changes. Images are fine too as long as the aspect ratio matches, e.g. with aspect-video.
Pitfall 3: after scrolling, only the category doesn't move#
This is the one that took the most time. Scroll down the list a little, open a card, and only the category badge crossfades instead of morphing. The heading and tags move just fine.
What was happening#
React only gives a view-transition-name to elements that are inside the viewport at the moment the transition starts. On top of that, the destination DOM is measured "at the scroll position of the page you came from." On the detail page at the time, the elements sat in this order from the top of the document:
- Category: 164–198px
- Heading: 210–285px
- Tags: 329–355px
The deeper you scroll, the earlier elements fall out of the viewport, top first. The category is the topmost shared element on the detail page, so it was the first to drop out. It only looked like "the category is unstable"; the cause had nothing to do with it being a badge and everything to do with its position.
| scrollY at click | Category | Heading | Tags |
|---|---|---|---|
| 150 | ✅ | ✅ | ✅ |
| 233 | ❌ | ✅ | ✅ |
| 300 | ❌ | ❌ | ✅ |
| 450 | ❌ | ❌ | ❌ |
Fix: scroll back before the transition starts#
I added a component that catches link clicks in the capture phase and scrolls back to the top before the transition begins. It uses the capture phase to get in before Next.js's <Link> starts the navigation. Jumping back from many screens down, though, makes things fly in "from somewhere unrelated to where you clicked," so it only scrolls back when you are within one screen of the top; anything deeper is left to the page-wide crossfade.
"use client";
import { useEffect } from "react";
export function ScrollResetBeforeNavigation() {
useEffect(() => {
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as Element | null)?.closest?.("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return;
// Don't steal the scroll from in-page anchors (table of contents, etc.)
if (url.pathname === window.location.pathname) return;
// Scrolling back from deeper than one screen morphs from the wrong starting point
if (window.scrollY === 0 || window.scrollY > window.innerHeight) return;
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
};
document.addEventListener("click", handleClick, true);
return () => document.removeEventListener("click", handleClick, true);
}, []);
return null;
}scrollTo(0, 0) wasn't doing anything#
The first version did not use behavior: 'instant'. It rewrote scrollBehavior and then called scrollTo(0, 0), because <html> has scroll-smooth and the scroll would otherwise animate.
But that did nothing at all. scrollTo only reads the computed style; it does not pick up a style change written just before it. React starts the transition about 1 ms after the click, and at that point the page had not scrolled a single pixel. These are the values right after each call, starting from scrollY = 300 on the same page:
| Approach | scrollY right after |
|---|---|
style.scrollBehavior = 'auto' → scrollTo(0, 0) | 300 |
The same with !important | 300 |
documentElement.scrollTop = 0 | 300 |
Style change → forced layout → scrollTo(0, 0) | 0 |
scrollTo({ top: 0, behavior: 'instant' }) | 0 |
The behavior in ScrollToOptions takes precedence over CSS scroll-behavior, so it does not depend on when styles are applied. It has fewer side effects than forcing a layout, so that is what I went with1.
Aligning the motion values with design tokens#
::view-transition-* are pseudo-elements outside the normal element tree, so Tailwind utilities don't reach them. Left alone they run with the UA default of 250 ms and linear easing, which matches neither the speed nor the feel of the rest of the site's animation (200 ms with its own easing).
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: var(--duration-normal);
}
/* Moving a shared element is "one movement there and back" */
::view-transition-group(*) {
animation-timing-function: var(--ease-default);
}
/* Swapping old and new is a "crossfade" */
::view-transition-old(*),
::view-transition-new(*) {
animation-timing-function: var(--ease-in-out);
}The anonymous names React assigns (_t_0_ and so on) aren't stable, so instead of naming each one, these rules use *.
Don't forget to cancel them for reduced motion#
Site-wide, animations on *, ::before, ::after are stopped inside @media (prefers-reduced-motion: reduce), but ::view-transition-* does not match that selector, because it belongs to a separate pseudo-element tree. You have to cancel it separately inside the same media query.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}Still unsolved#
- The browser back button doesn't animate. Navigations triggered by popstate don't call
startViewTransition. This is a React / Next.js limitation, so for now the detail page has breadcrumbs that go back through a<Link>. - Dates are not shared elements. The date looks different on the list and the detail page, so morphing it would distort its shape. Whether to make them look the same has to be decided first.
- Related-post cards get no names. When you go from post A to post B and B's related posts include A's card, A's heading would fly off to that card at the bottom of the page.
Summary#
When shared-element transitions work, they look like magic; when they break, they just quietly fall back to a plain fade. That is exactly why it paid off to settle these three rules up front:
- Always scope names with the slug of the owning entity
- For text used as a shared element, make the box hug the text on both sides (
w-fit) - Before the transition, scroll back to the top if you are within one screen of it
I used Playwright for the measurements, but locator.click() scrolls the page internally, so to click while keeping the scroll position you need page.mouse.click(x, y). Until I noticed that, I was puzzled why scrollY came out as 233 every time, whether I set it to 300 or 450 (the page was being scrolled to the card's top edge, offset by the scroll-padding-top on html).
Footnotes
-
Inserting a forced layout (a read such as
getBoundingClientRect()) also makes thescrollBehaviorrewrite take effect, but it costs an extra layout pass, and you have to keep explaining in a comment why that read is there. ↩