ScrollTrigger

Tie animations to the scroll position: reveal, scrub and pin.

What it is

ScrollTrigger connects a GSAP animation to scrolling. start and end say where ("top 85%": when the element's top reaches 85% down the viewport). toggleActions plays an animation when it's reached; scrub makes its progress follow the scrollbar; pin holds an element in place while you scroll past. Turn on the markers below to see the start and end lines.

When to use it

  • Reveal content as it scrolls into view
  • Storytelling pages and product tours
  • Progress indicators and parallax
Official docs ↗

Scroll down through the demos

1. Reveal on enter

Enters at 85% of the viewport
Plays once, reverses when you scroll back up
toggleActions controls all four events

2. Scrub: progress follows the scrollbar

Scroll up and down slowly: the bar and the square move with you. scrub: 0.5 adds half a second of smoothing to the square.

3. Pin and scroll sideways

1 / 4Design
2 / 4Animate
3 / 4Scroll
4 / 4Ship

The section pins while you scroll, then lets go after the last panel.

Show code · src/app/gsap-scroll/ScrollDemos.tsx
"use client";

import { useRef, useState } from "react";
import { useMotion } from "@/components/motion/MotionProvider";
import { gsap, useGSAP } from "@/lib/gsap";
import { cx } from "@/components/ui";

const PANELS = ["Design", "Animate", "Scroll", "Ship"];

// Three ScrollTrigger patterns. With reduced motion, the same content shows
// without movement: cards simply fade in, nothing is pinned or scrubbed.
export function ScrollDemos() {
  const { reduced } = useMotion();
  const root = useRef<HTMLDivElement>(null);
  const [markers, setMarkers] = useState(false);

  useGSAP(() => {
    // 1. Reveal: each card animates in once as it enters the viewport.
    gsap.utils.toArray<HTMLElement>(".reveal-card").forEach((card) => {
      gsap.from(card, {
        opacity: 0,
        y: reduced ? 0 : 60,
        duration: reduced ? 0.4 : 0.8,
        ease: "power2.out",
        scrollTrigger: { trigger: card, start: "top 85%", toggleActions: "play none none reverse", markers },
      });
    });

    if (reduced) return;

    // 2. Scrub: the animation's progress follows the scroll position exactly.
    gsap.to(".scrub-bar", {
      scaleX: 1,
      ease: "none",
      scrollTrigger: { trigger: ".scrub-section", start: "top 80%", end: "bottom 30%", scrub: true, markers },
    });
    gsap.to(".scrub-spinner", {
      rotation: 720,
      ease: "none",
      scrollTrigger: { trigger: ".scrub-section", start: "top 80%", end: "bottom 30%", scrub: 0.5, markers },
    });

    // 3. Pin: the section stays put while scrolling moves the panels sideways.
    const track = root.current!.querySelector<HTMLElement>(".panel-track")!;
    gsap.to(track, {
      x: () => -(track.scrollWidth - track.clientWidth),
      ease: "none",
      scrollTrigger: {
        trigger: ".pin-section",
        start: "top 15%",
        end: () => `+=${track.scrollWidth - track.clientWidth}`,
        pin: true,
        scrub: 1,
        // ScrollTrigger measures positions up front; this re-measures on resize.
        invalidateOnRefresh: true,
        markers,
      },
    });
  }, { scope: root, dependencies: [reduced, markers], revertOnUpdate: true });

  return (
    <div ref={root}>
      <label className="mb-2 flex items-center gap-1.5 text-sm">
        <input type="checkbox" checked={markers} onChange={(e) => setMarkers(e.target.checked)} /> Show ScrollTrigger markers (start/end lines)
      </label>

      <h3>1. Reveal on enter</h3>
      <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2.5">
        {["Enters at 85% of the viewport", "Plays once, reverses when you scroll back up", "toggleActions controls all four events"].map((t) => (
          <div key={t} className="reveal-card rounded-lg border border-line bg-bg px-3 py-2.5">{t}</div>
        ))}
      </div>

      <h3>2. Scrub: progress follows the scrollbar</h3>
      <div className="scrub-section grid gap-3 rounded-lg border border-line bg-bg px-3 py-2.5">
        <div className="h-2.5 overflow-hidden rounded-full bg-code"><div className="scrub-bar h-full origin-left bg-accent" style={{ transform: reduced ? "scaleX(1)" : "scaleX(0)" }} /></div>
        <div className="scrub-spinner size-16 rounded-xl bg-accent" />
        <p className="text-sm text-muted">{reduced ? "Reduced motion: shown in its finished state." : "Scroll up and down slowly: the bar and the square move with you. scrub: 0.5 adds half a second of smoothing to the square."}</p>
      </div>

      <h3>3. Pin and scroll sideways</h3>
      <div className="pin-section overflow-hidden rounded-xl">
        <div className={cx("panel-track flex w-full gap-4", reduced && "flex-wrap")}>
          {PANELS.map((p, i) => <div key={p} className={cx("flex flex-col justify-between rounded-xl bg-linear-135 from-accent to-pink p-4 text-white", reduced ? "h-30 flex-1 basis-45 text-xl" : "h-55 flex-none basis-[min(70%,420px)] text-[1.6rem]")}><span className="text-sm text-white/75">{i + 1} / {PANELS.length}</span><strong>{p}</strong></div>)}
        </div>
      </div>
      <p className="text-sm text-muted">{reduced ? "Reduced motion: the panels are listed instead of pinned." : "The section pins while you scroll, then lets go after the last panel."}</p>
      <div className="h-[40vh]" aria-hidden="true" />
    </div>
  );
}
Show code · src/lib/gsap.ts
"use client";

import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import { Flip } from "gsap/Flip";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";

// Registers every GSAP plugin the lab uses, once. All of them ship in the
// free `gsap` package (they were paid "Club" plugins before 2025). Import
// from here, in Client Components only: plugins touch window and document.
gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText, Flip, DrawSVGPlugin, MorphSVGPlugin);

export { DrawSVGPlugin, Flip, gsap, MorphSVGPlugin, ScrollTrigger, SplitText, useGSAP };