(02) — All notesNote 02 / 03

A shared-element page transition in the Next.js App Router

Growing a project cover into the next page without the View Transitions API: measuring the frame, reusing the cached image, and handing control back once the route has changed.

Published
Reading time
3 min
Topics
Next.js, GSAP, Motion

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 calling router.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.

About the author

I'm Muhammad Tanveer, a full-stack developer at XtroEdge in Faisalabad, Pakistan. I build web products end to end with React, Next.js, TypeScript and Node.js.

Next note — 2 min readCutting Total Blocking Time from 200⁠–⁠500ms to 60⁠–⁠80ms