Skip to content
Docs · 11 pages

Docs / Patterns

View transitions

How every component survives ClientRouter navigation, and how to keep GSAP and Lenis working too.

2 min readThis page as MarkdownSource on GitHub

<ClientRouter /> swaps the DOM without a reload and skips bundled scripts that already ran (Astro docs). Anything set up on the first page now points at detached nodes; anything not set up again on the next page does nothing. Five threads on GSAP’s forum between April and August 2024 report it for ScrollTrigger and ScrollSmoother (listed in the GSAP guide), and Flowbite’s carousel stops working after a back navigation (themesberg/flowbite#864, open since April 2024).

What the library does

Every scripted component binds through the runtime: it queries all instances on astro:page-load, tears down on astro:before-swap, never double-binds, and never listens to astro:page-load alone (it does not fire without the router). CSS-only components need nothing: fresh elements restart their own animations.

On every swap the router gives <html> the next page’s attributes, which drops data-ma-js, the gate entrance effects hide behind. The integration’s gate script sets it again after every swap, and the runtime records it before the swap and restores it after. Without that, every reveal after the first navigation would either flash or never play.

All of this is verified in CI on Astro 5, 6 and 7 by navigating a real browser between pages and counting bindings.

GSAP and ScrollTrigger

GSAP already has the right tool for this: gsap.context() and revert(). The adapter calls them at the right moment.

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 });
  ScrollTrigger.create({ trigger: '.pinned', pin: true, start: 'top top', end: '+=600' });
}, { scrollTrigger: ScrollTrigger });

gsapPage runs setup inside a context once per page view, reverts it (tweens, triggers, pin-spacers, inline styles) before the next swap, and calls ScrollTrigger.refresh() on the frame after setup and again when web fonts settle. Put it in a layout script so it registers once.

Lenis

import Lenis from 'lenis';
import { lenisPage } from '@moonarc/core/lenis';

const lenis = lenisPage(() => new Lenis(), { gsap, scrollTrigger: ScrollTrigger });

One instance for the life of the document (the document persists across navigations, so must the scroller), driven by gsap.ticker when given. After every swap it stops an animation still running from the previous page, resyncs Lenis to the position the router set, and writes Lenis’ classes on <html> again, which the swap replaced. Without that stop, a link clicked or the back button pressed while Lenis is still easing carries the next page to the old page’s target: 1200 px down instead of the top, measured with Lenis 1.3.26. The Lenis guide has the details.

Your own scripts

Use onMount and onPage from the runtime instead of astro:page-load listeners. See runtime.

Page transitions themselves

transition:name, transition:animate and transition:persist are Astro’s own. A component inside an element kept by transition:persist is released before the swap and bound again after it, like any other: the element, its DOM and its media survive, and the component’s setup runs once more on it. Keep page-level transitions between 150 and 300 ms; anything longer sits in front of the user’s intent.