Skip to content

Resources / Guide · 2026-09-12

GSAP ScrollTrigger with Astro View Transitions

Why ScrollTrigger stops working after a ClientRouter navigation, and the adapter that sets it up on every page and cleans up before the next one.

2 min readThis page as MarkdownSource on GitHub

The symptom

Your ScrollTrigger animations work on the first page. You add <ClientRouter /> for smooth navigation, click a link, and nothing on the next page animates or pins. Sites without the router are not affected, because every page is a full load that runs its scripts again.

Five threads on GSAP’s forum between April and August 2024 report it: ScrollTrigger breaking on a second visit, ScrollSmoother stopping after a navigation, pinning with ScrollSmoother, markers that need a reload and setting GSAP up with view transitions. A GreenSock administrator answers all five the same way: clean up the previous page’s instances and create new ones after each navigation.

Why it breaks

ClientRouter swaps the DOM without a reload, and Astro runs a bundled module script only once per visit (Astro docs). Your gsap.from() and ScrollTrigger.create() calls ran once, against the first page’s elements. We measured it with GSAP 3.15 on Astro 7.3.2 in Chromium, Firefox and WebKit: after one navigation both triggers still exist, neither points at an element on the page, and the new page has no pin and no tween. Moving the setup into an astro:page-load listener makes every page animate, but nothing kills the triggers of the page you left, so the count goes 2, 4, 6, 8 over three navigations.

The fix

GSAP already has the right primitive. gsap.context() records everything created inside it; revert() kills tweens and triggers, removes pin-spacers and restores inline styles. The missing piece is calling them at the right moments in Astro’s lifecycle. gsapPage does that:

// src/layouts/Base.astro — inside <script>
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { gsapPage } from '@moonarc/core/gsap';

gsap.registerPlugin(ScrollTrigger);

gsapPage(
  gsap,
  () => {
    gsap.from('.hero h1', { y: 24, opacity: 0, duration: 0.7, ease: 'power3.out' });
    ScrollTrigger.create({ trigger: '.pinned', pin: true, start: 'top top', end: '+=600' });
  },
  { scrollTrigger: ScrollTrigger },
);

What happens now, on every page view:

  1. setup runs inside a fresh gsap.context(), on the first load and after every navigation, exactly once each.
  2. ScrollTrigger.refresh() runs on the next frame, and again when document.fonts.ready resolves, so positions are measured against the real layout.
  3. Before the next swap, the context is reverted: no leaked triggers, no orphaned pin-spacer, no inline styles left on elements about to be replaced.

Put it in a layout script so it registers once. It also works on pages without the router.

How the fix is tested

A browser test navigates between two pages with pinned sections on Astro 5, 6 and 7 and asserts: page A has exactly one trigger and one pin-spacer, page B exactly two and two, back to A exactly one and one, and the tween applied on every page.

Install

npx astro add moonarc
npm i gsap

gsap is passed in, never bundled by us, so you keep one GSAP instance and your plugin registrations. See view transitions.

Animation libraries for Astro, measured shows what a GSAP reveal set up once in a layout does after a navigation, and what the same setup inside gsap.context() on every page does, beside AOS, Motion, AstroAnimate, astro-reveal and Reveal.