ScrollTrigger is the most powerful scroll animation tool available for the web right now. I’ve been using it on production projects for three years and there are a handful of patterns I reach for constantly. This is a practical reference, not a beginner tutorial — I’m assuming you know JavaScript and have at least seen GSAP before.

Why ScrollTrigger Over CSS Scroll-Driven Animations

CSS animation-timeline: scroll() is genuinely exciting and I’ve been watching it closely. But there are three reasons I still default to ScrollTrigger in production:

Control. ScrollTrigger gives you onEnter, onLeave, onEnterBack, onLeaveBack callbacks. You can tie animations to specific DOM events, pin elements, create horizontal scroll sections, and scrub animations precisely to scroll position. CSS scroll-driven animations don’t have equivalent debugging or callback capabilities yet.

Browser support. animation-timeline: scroll() currently requires a flag in Safari and isn’t supported in Firefox without a polyfill. ScrollTrigger works everywhere.

Debugging. The markers: true option in ScrollTrigger draws visual start/end markers directly on the page. When you’re tweaking a complex sequence, this saves hours.

Pattern 1: Basic Fade-In Reveal with Stagger

This is the most common animation on any site with sections — elements fade up as they scroll into view. Here’s the pattern:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

// Reveal a group of cards with stagger
gsap.from(".card", {
  scrollTrigger: {
    trigger: ".cards-section",
    start: "top 80%",
    // markers: true,  // Enable during development
  },
  y: 60,
  opacity: 0,
  duration: 0.8,
  stagger: 0.12,
  ease: "power2.out",
});

Key values to understand:

  • start: "top 80%" — animation fires when the top of .cards-section hits 80% down the viewport (i.e., 20% before it’s fully visible)
  • stagger: 0.12 — each .card starts animating 120ms after the previous one
  • ease: "power2.out" — starts fast, decelerates smoothly

For repeated reveals (elements that should animate every time they enter the viewport, not just once), add toggleActions: "play reset play reset".

Pattern 2: Pinned Section with Scrub

Pinning a section keeps it fixed on screen while the user scrolls, letting you tie an animation timeline to scroll progress. This is how you build those “scroll to reveal” hero sections.

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: "#pinned-section",
    start: "top top",
    end: "+=150%",    // Pin for 150% of viewport height worth of scroll
    pin: true,
    scrub: 1,         // 1 second smoothing on the scrub
    anticipatePin: 1, // Prevents jumpiness at pin start
  },
});

// Each tween in the timeline maps to a portion of the scroll range
tl.from(".headline", { y: 100, opacity: 0 })
  .from(".subheadline", { y: 60, opacity: 0 }, "-=0.3")
  .to(".background-shape", { scale: 1.4, opacity: 0 }, 0)
  .from(".cta-button", { y: 40, opacity: 0 }, "+=0.2");

scrub: 1 is the key here. It smooths the scroll-to-animation mapping by 1 second — without it, scrubbed animations feel jittery on trackpads. scrub: true is instant (no smoothing); scrub: 2 is even more delayed and feels sluggish. 1 is usually right.

anticipatePin: 1 prevents the jarring jump that sometimes happens when a pinned element snaps into position.

Pattern 3: Horizontal Scroll Section

A horizontal scroll section uses pinning combined with an x-translate animation. The section pins while the inner content slides horizontally:

const panels = gsap.utils.toArray(".h-panel");

gsap.to(panels, {
  xPercent: -100 * (panels.length - 1),
  ease: "none",  // Linear — crucial for horizontal scroll feel
  scrollTrigger: {
    trigger: ".h-scroll-container",
    pin: true,
    scrub: 1,
    snap: {
      snapTo: 1 / (panels.length - 1),
      duration: { min: 0.2, max: 0.4 },
      delay: 0.1,
      ease: "power1.inOut",
    },
    end: () => "+=" + document.querySelector(".h-scroll-container")!.offsetWidth,
  },
});

The ease: "none" on the main tween is critical — you want a 1:1 mapping between scroll position and horizontal position. Any easing here will make the movement feel wrong.

The snap config is optional but usually desired — it snaps to panel boundaries after a scroll gesture ends. duration controls how fast the snap animation plays; keep it short (0.2–0.4s) or it feels sluggish.

Performance Tips

A few things that matter in production:

will-change sparingly. GSAP handles GPU promotion automatically for most animations, but for elements that animate on scrub (every frame), adding will-change: transform can help on mobile. Remove it after the animation completes with onComplete: () => gsap.set(el, { willChange: "auto" }).

Kill triggers on cleanup. If you’re initializing ScrollTrigger in a component lifecycle, kill it when the component unmounts. Without this, you’ll get duplicate triggers on navigation with SPA routing:

const trigger = ScrollTrigger.create({ /* ... */ });

// In cleanup (React useEffect return, Vue onUnmounted, etc.)
trigger.kill();

// Or kill all at once:
ScrollTrigger.getAll().forEach(t => t.kill());

markers: true in development. The visual markers make it immediately obvious where your start/end points are firing. Just make sure they never ship to production — I keep a DEV constant that controls this.

Batch DOM-reading operations. GSAP is efficient, but if you’re doing complex calculations in onUpdate callbacks, batch your reads and writes to avoid layout thrashing:

ScrollTrigger.addEventListener("scrollStart", () => {
  // Safe to read DOM here
  const width = container.offsetWidth;
});

Integration with Astro

Astro is static-first, so you need to initialize GSAP on the client. There are two good options:

For page-level animations, use a <script> tag in your .astro component. Astro will bundle it and it runs after the DOM is ready:

<script>
  import gsap from "gsap";
  import { ScrollTrigger } from "gsap/ScrollTrigger";

  gsap.registerPlugin(ScrollTrigger);

  // Your ScrollTrigger setup here
  gsap.from(".hero-title", {
    scrollTrigger: { trigger: ".hero", start: "top 90%" },
    y: 80,
    opacity: 0,
    duration: 1,
  });
</script>

For interactive island components, use client:load or client:visible. client:visible is usually better — it defers hydration until the component is in the viewport:

<!-- Hydrate only when this section scrolls into view -->
<AnimatedSection client:visible />

Inside that component (React/Svelte/Vue), initialize ScrollTrigger in the appropriate lifecycle hook (useEffect for React, onMounted for Vue). Always return a cleanup function that kills the triggers.

One gotcha with Astro’s View Transitions (ClientRouter): when navigating between pages, the old page’s ScrollTrigger instances aren’t automatically killed. Add this to your main script:

document.addEventListener("astro:before-swap", () => {
  ScrollTrigger.getAll().forEach(t => t.kill());
});

That covers 80% of what I use in production. The ScrollTrigger API has more depth — timeline sequencing across sections, velocity detection, container scrolling — but these three patterns handle the majority of animation requirements on any given project.