Dhaka--:--
All notes

30 Jul 2026 · 2 min read

Page transitions in the Next.js App Router with GSAP

A cover, a route change, and a reveal. How to orchestrate them reliably, and the three bugs that cost me the most time.

  • Next.js
  • GSAP
  • Motion

The browser's View Transitions API is lovely, but support is still uneven and you give up control over timing. For a portfolio where every navigation should feel designed, I use a small GSAP-driven engine that works everywhere.

The sequence

  1. A click is intercepted by a custom link component
  2. A full-screen curtain animates in and covers the page
  3. router.push() runs while hidden
  4. When the new pathname mounts, scroll jumps to the top
  5. The curtain lifts, and the new page plays its own entrance

The trick is step 3 to 4: you must wait until the new route has actually rendered. A promise resolved from an effect on usePathname() does exactly that:

const mounted = new Promise<void>((resolve) => {
  arrived.current = resolve;
  setTimeout(resolve, 6000); // never strand the visitor behind the curtain
});

router.push(target, { scroll: false });
await Promise.all([mounted, wait(300)]);
useEffect(() => {
  if (arrived.current) {
    arrived.current();
    arrived.current = null;
  }
}, [pathname]);

Bug 1: CSS and GSAP both own transform

I gave the curtain panels transform: translateY(100%) in CSS, then animated yPercent with GSAP. GSAP read the CSS offset as pixels and added its own, so the panels ended up off-screen and the transition did nothing. Pick one owner per property. Hide things with visibility and let GSAP set the transform.

Bug 2: React erases classes you add from outside

My reveal system added an .is-in class when elements scrolled into view. Any component whose className changed on re-render lost it, and its content vanished. Use data attributes for externally-managed state; React leaves attributes it doesn't know about alone.

Bug 3: scroll triggers measure the old layout

After filtering a grid or opening an accordion, ScrollTrigger positions are stale. Call ScrollTrigger.refresh() when layout changes settle, and for user-driven changes just reveal the affected elements directly.

Respect the user

  • Skip the whole thing for prefers-reduced-motion
  • Let modifier-clicks (new tab) behave natively
  • Keep browser Back working, since it restores scroll position itself
  • Put a timeout on every "wait for the route" promise

Why bother

Because the cheapest way to make a site feel premium is for every state change to go somewhere. Done carefully, it adds about 1.5 seconds of choreography per navigation and zero broken pages.