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

1. On click, measure the source element with `getBoundingClientRect()`.
2. Clone it into a `position: fixed` overlay at exactly those coordinates.
3. Animate the overlay to the destination banner's geometry.
4. Fire `router.push()` about a third of the way through — early enough that React has committed by the time the animation lands.
5. The destination page calls an `end()` function on mount, which fades the overlay out.

```ts
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.