When you open a project from the work index on this site, its cover grows from the list to fill the screen, the project name rises in, and the frame lifts away to show the case study. It works across routes in the Next.js App Router, in every browser, without the View Transitions API.
The pieces
-
TransitionLink: a normal<a>that intercepts plain left clicks and asks for an exit before callingrouter.push. -
PageTransitionOverlay: lives in the root layout, so it survives navigation. It owns the overlay elements and all the timelines. - A small module,
lib/transition.ts, that connects the two with a registered function instead of React context.
The overlay registers its exit function on mount, and the link calls it. Context would have meant a client provider around the whole tree just to pass one callback around.
let _playExit: ExitFn = (cb) => cb()
export function setExitFn(fn: ExitFn) {
_playExit = fn
}
export function triggerExit(cb: () => void, label?: string, from?: MorphFrom | null) {
_playExit(cb, label, from)
}
The default just navigates, so if the overlay isn’t mounted, links still work.
Respect the browser first
Before anything animates, the link lets the browser handle Cmd/Ctrl/Shift/Alt clicks and middle clicks, so opening a case study in a new tab works as usual. With reduced motion turned on, the exit function calls straight through to navigation.
Measuring the shared element
The morph source is the image’s clipping frame, not the image itself, because covers are cropped with object-fit and their own box can be larger than what you see. If the frame is tiny or off-screen, there’s nothing sensible to grow from, so the link falls back to the plain ink panel:
const rect = frame.getBoundingClientRect()
const onScreen = rect.width > 40 && rect.height > 24 && rect.bottom > 0 && rect.top < window.innerHeight
const src = img.currentSrc || img.src
return onScreen && src ? { rect, src } : null
No blank frame
The overlay image is given the same URL the page is already showing (currentSrc, so the same next/image rendition), which makes it a cache hit. It still waits for img.decode() before the first frame, otherwise there is a single empty frame at the start of the grow that you can see on slower machines.
Thumbnails are small, though, and a 640px image stretched to full screen looks soft. While the frame grows, a full-width rendition is requested by rewriting the w= parameter of the optimised image URL. It is swapped in only if it decodes while the overlay is still covering the screen.
Grow, navigate, lift
The grow is a GSAP timeline on left/top/width/height and the corner radius, with expo.inOut. When it completes it calls router.push. At the end the frame switches from measured pixels to 100%, so a resize while the next route loads doesn’t leave a gap.
The overlay watches usePathname(). When the path changes it scrolls to the top, waits one animation frame so the new page can register its ScrollTriggers, refreshes them, and then reveals: the label leaves first, then the frame slides up while the picture inside it moves down. Because the image lags behind its frame, the exit reads as a curtain rather than a card sliding away.
Never trap the user
Navigation can stall on a slow network or fail outright. A five-second timeout reveals the page regardless, so nobody is left staring at a full-screen image. The overlay is aria-hidden and focus never moves into it; to assistive technology this is an ordinary link to an ordinary page.
Originally published on my site. I'm Muhammad Tanveer, a full-stack developer in Faisalabad, Pakistan: muhammad-tanveer-dev.vercel.app.
Top comments (1)
The five-second timeout that reveals the page regardless is the part I went back and forth on in my own route transition. My first version also retreated after a fixed delay, and on a throttled Slow 4G profile it kept lifting the overlay over the old page, with the new one popping in a second or more later. Two changes fixed it: prefetching the route when the exit starts rather than when it ends, and a wait state where the overlay dims to a dark veil with a thin progress line until the page really arrives, capped at 12 s. Have you tried calling router.prefetch on click, so the grow and the network overlap?