Shared-element page transitions without View Transitions
How the card-to-case-study transition on this site works, why it uses a cloned node instead of the View Transitions API, and the one rule that keeps it from breaking.
Clicking a project card on this site expands its screenshot into the banner of the case study. It looks like the View Transitions API. It isn't.
Why not View Transitions
The API is genuinely good, but in a Next.js App Router build it means depending on an experimental flag, and it degrades to nothing in browsers that don't support it. A cloned node behaves identically everywhere and I own every frame of it.
There's a second, better reason. The clone stays on screen through the route change — so a slow page never shows a blank frame. The transition doubles as a loading cover.
The shape of it
- On click, measure the source element with
getBoundingClientRect(). - Clone it into a
position: fixedoverlay at exactly those coordinates. - Animate the overlay to the destination banner's geometry.
- Fire
router.push()about a third of the way through — early enough that React has committed by the time the animation lands. - The destination page calls an
end()function on mount, which fades the overlay out.
const rect = source.getBoundingClientRect();
const overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "fixed",
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
zIndex: "300",
overflow: "hidden",
});
overlay.appendChild(source.cloneNode(true));
document.body.appendChild(overlay);
gsap
.timeline()
.to(overlay, {
left: 0,
top: 0,
width: "100vw",
height: "62svh",
duration: 0.72,
ease: "expo.inOut",
})
.add(() => router.push(href), 0.34);The rule
One element pair per navigation. Two or three shared elements moving at once is where these transitions go wrong — the timings compound, one lands early, and the whole thing reads as jitter. Pick the element the eye is already on and move only that.
Always leave a fallback
Under prefers-reduced-motion, skip all of it and navigate immediately. And always set a timeout that removes the overlay regardless of what happens on the destination — if the page throws during render, an orphaned fixed overlay covers the entire site with no way to dismiss it.
- gsap
- flip
- next.js
- transitions