Files
cycle-atlas/.agents/skills/gsap-web/references/scrolltrigger-cookbook.md
T

5.5 KiB

ScrollTrigger Cookbook

Runnable patterns for GSAP 3.12+. Register first:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

1. Pin a section and scrub a timeline through it

The most common scrollytelling pattern: hold a section fixed and drive a multi-step timeline with scroll.

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: ".story",
    start: "top top",
    end: "+=3000",   // total scroll distance the pin lasts
    pin: true,
    scrub: 1,
    anticipatePin: 1, // reduces a 1-frame jump when pinning fast scroll
  },
});

tl.from(".story .step-1", { autoAlpha: 0, y: 60 })
  .from(".story .step-2", { autoAlpha: 0, y: 60 })
  .to(".story .bg", { scale: 1.2, ease: "none" }, 0); // runs across whole timeline

Tips:

  • Each tween occupies an equal slice of the pinned distance unless you set durations/positions.
  • pinSpacing: true (default) adds padding so content below does not jump. Set false only when overlaying.
  • If the pinned element is inside a transformed/overflow ancestor, pinning breaks — pin a top-level element or set pinReparent: true.

2. Horizontal scroll (panels move sideways as the page scrolls vertically)

<section class="h-wrap"><div class="track">
  <div class="panel">1</div><div class="panel">2</div><div class="panel">3</div>
</div></section>
.track { display: flex; width: max-content; }
.panel { width: 100vw; height: 100vh; flex: 0 0 100vw; }
const track = document.querySelector(".track");
gsap.to(track, {
  x: () => -(track.scrollWidth - window.innerWidth), // function = recalculated on refresh
  ease: "none",
  scrollTrigger: {
    trigger: ".h-wrap",
    pin: true,
    scrub: 1,
    end: () => "+=" + (track.scrollWidth - window.innerWidth),
    invalidateOnRefresh: true, // re-run the x function on resize
  },
});

invalidateOnRefresh: true plus function-based values is the correct way to stay responsive.

3. Snap to sections

gsap.to(".sections", {
  scrollTrigger: {
    trigger: ".sections",
    pin: true,
    scrub: 1,
    end: "+=4000",
    snap: {
      snapTo: 1 / 4,        // 5 evenly spaced points (labels also possible: "labels")
      duration: { min: 0.2, max: 0.6 },
      delay: 0.05,
      ease: "power1.inOut",
    },
  },
});

To snap to timeline labels: build a labeled timeline and use snap: "labelsDirectional".

4. Batch reveals (performant many-element fade-in)

Instead of creating one ScrollTrigger per card (expensive at scale), batch them:

ScrollTrigger.batch(".card", {
  start: "top 85%",
  onEnter: (els) =>
    gsap.from(els, { autoAlpha: 0, y: 40, stagger: 0.12, overwrite: true }),
  onLeaveBack: (els) =>
    gsap.to(els, { autoAlpha: 0, y: 40, stagger: 0.12, overwrite: true }),
});

Set the initial hidden state to avoid a flash before JS runs: gsap.set(".card", { autoAlpha: 0 }).

5. Parallax layers

gsap.utils.toArray(".parallax").forEach((layer) => {
  const depth = layer.dataset.depth || 0.3; // 0 = static, 1 = scrolls with page
  gsap.to(layer, {
    yPercent: -depth * 100,
    ease: "none",
    scrollTrigger: { trigger: layer.parentElement, scrub: true, start: "top bottom", end: "bottom top" },
  });
});

6. Linked timelines / containerAnimation (triggers inside horizontal scroll)

To trigger animations off elements that move horizontally inside a pinned horizontal scroller, pass the horizontal tween as containerAnimation:

const scrollTween = gsap.to(track, { x: -2000, ease: "none",
  scrollTrigger: { trigger: ".h-wrap", pin: true, scrub: 1, end: "+=2000" } });

gsap.from(".panel-2 .title", {
  autoAlpha: 0, y: 50,
  scrollTrigger: { trigger: ".panel-2 .title", containerAnimation: scrollTween, start: "left center" },
});

Note start: "left center" uses horizontal keywords because the trigger moves horizontally.

7. Refresh on dynamic content

Images and lazy content change layout after triggers are computed. Refresh when ready:

window.addEventListener("load", () => ScrollTrigger.refresh());
imagesLoaded(document.body, () => ScrollTrigger.refresh()); // if using imagesLoaded lib

For accordions/tabs that change height, call ScrollTrigger.refresh() after the layout settles.

8. SPA / React cleanup (critical)

Orphaned ScrollTriggers cause ghost pins, leaks, and stacking bugs on route changes. Always scope and revert.

React with @gsap/react:

import { useGSAP } from "@gsap/react";

function Section() {
  const root = useRef(null);
  useGSAP(() => {
    gsap.from(".item", {
      autoAlpha: 0, y: 40, stagger: 0.1,
      scrollTrigger: { trigger: ".item", start: "top 80%" },
    });
  }, { scope: root }); // auto-reverts on unmount
  return <div ref={root}>...</div>;
}

Plain SPA / manual:

const ctx = gsap.context(() => { /* all gsap code */ }, rootEl);
// on teardown / route leave:
ctx.revert();

Nuclear option (e.g. global router hook): ScrollTrigger.getAll().forEach((t) => t.kill());

Debugging checklist

  • Triggers fire at wrong scroll position → likely created before images loaded; call ScrollTrigger.refresh().
  • Pinned element jumps to top-left → it has a transformed/will-change/overflow:hidden ancestor; pin higher up.
  • Animation plays once then never resets → set appropriate toggleActions or use scrub.
  • Horizontal scroll not responsive → use function-based x/end + invalidateOnRefresh: true.
  • Markers misaligned → leftover triggers from previous render; ensure cleanup runs.