Skip to content
Zumkai

ScrollTrigger: the 8 patterns that cover 90% of cases

Eight ScrollTrigger recipes with the minimal code for each, the patterns almost nobody reads in the docs, and what breaks once you put it all inside React.

  • gsap
  • scrolltrigger
Card with the eight ScrollTrigger patterns and the default values of start, end and toggleActions.
Contents
  1. The four parameters that explain almost every bug
  2. 1. Reveal on entering the viewport
  3. 2. Tie the animation to the scrollbar
  4. 3. Pin the section while something happens
  5. 4. Horizontal scrolling inside vertical
  6. 5. Reveal lists in batches
  7. 6. Snapping between sections
  8. 7. Progress indicator
  9. 8. Responsive and respecting reduced-motion
  10. The eight side by side, and where CSS already solves it
  11. What breaks inside React
  12. The errors that cost the most time
  13. When the animations trip over each other
  14. Frequently asked questions
  15. Sources

ScrollTrigger's documentation holds dozens of properties. In practice, eight combinations solve almost everything that shows up on an agency site, and the rest exist for cases you will meet once a year.

This post brings the eight with the minimal code for each, the default values that explain why your animation fires at the wrong moment, and the part most tutorials skip: what happens once you put this inside a React component.

Method note: the "90%" in the title is an editorial judgment based on the motion projects I have delivered, not a measurement. No public survey of ScrollTrigger property usage frequency exists, and I ran none. Treat the number as a priority framing, not as a statistic.

I checked everything here against the official documentation at version 3.15.0, published 13 April 2026.

The four parameters that explain almost every bug

Before the recipes, four default values. Knowing the four eliminates most of the trial-and-error sessions with markers: true on.

PropertyDefaultWhat it means
start"top bottom", or "top top" when a pin existsThe trigger's top touches the bottom of the viewport
end"bottom top"The trigger's bottom passes the top of the viewport
toggleActions"play none none none"Actions on the four edges: entering, leaving, entering back, leaving back
pinSpacingtrue, and false when the container is display: flexSpace reserved so the content avoids jumping when the pin releases

The default start confuses people most. "top bottom" means the animation fires the instant the element's top touches the bottom of the screen, meaning as soon as it starts to appear. Almost always what you want is letting the element enter in full before animating:

js
gsap.from(".card", {
  y: 40,
  opacity: 0,
  scrollTrigger: {
    trigger: ".card",
    start: "top 85%",   // the card's top at 85% of the viewport height
  },
});

While you are adjusting, markers: true draws the start and end lines on the screen. Remove it before shipping.

1. Reveal on entering the viewport

The most common pattern, and the one needing the least configuration. toggleActions controls what happens on the four edges, in order: entering, leaving, entering back, leaving back.

js
gsap.from(".section", {
  y: 60,
  opacity: 0,
  duration: 0.8,
  ease: "power2.out",
  scrollTrigger: {
    trigger: ".section",
    start: "top 80%",
    toggleActions: "play none none reverse",
  },
});

With "play none none reverse", the element animates on entry and undoes when you scroll back up. If the reveal should happen once and stay, a shortcut exists:

js
scrollTrigger: { trigger: ".section", start: "top 80%", once: true }

once kills the trigger once it reaches the end, which also removes the monitoring cost. For a list with dozens of elements, that matters.

2. Tie the animation to the scrollbar

Here the animation loses its own duration and the scroll position takes over control.

js
gsap.to(".panel", {
  xPercent: -100,
  ease: "none",
  scrollTrigger: {
    trigger: ".panel",
    start: "top top",
    end: "+=1500",
    scrub: 1,
  },
});

The choice between scrub: true and scrub: 1 changes the whole feel. With true, the animation follows the bar with no lag at all, and every jolt of a finger on the trackpad shows up on screen. With a number, you define how many seconds the playhead takes to catch up to the bar's position, which smooths the reading without touching the page's scroll.

scrub: 0.5 to scrub: 1.5 covers almost every real case. Above that, the animation starts feeling disconnected from the gesture.

With scrub, use ease: "none" on the animation. Easing and scrub compete for the same control and the result turns erratic.

3. Pin the section while something happens

The pin is what makes a section lock to the screen while its content advances.

js
const tl = gsap.timeline({
  scrollTrigger: {
    trigger: ".hero",
    start: "top top",
    end: "+=2000",
    pin: true,
    scrub: 1,
  },
});

tl.to(".hero-title", { scale: 1.4, opacity: 0 })
  .from(".hero-detail", { yPercent: 40, opacity: 0 }, "<");

Two things the documentation warns about that pay for themselves before you start debugging.

Never animate the pinned element. ScrollTrigger precomputes the positions to render faster, and touching the element it is measuring throws the arithmetic away. Animate the children, as in the example above.

pinSpacing reserves the space. By default it adds padding at the bottom, or on the right under horizontal: true, so the following content avoids jumping when the pin releases. The exception is a container using display: flex, where it arrives as false and you have to solve the spacing by hand.

4. Horizontal scrolling inside vertical

The gallery effect that moves sideways while you scroll down. The part almost nobody implements right is firing animations inside that horizontal track.

js
const track = gsap.to(".rail", {
  x: () => -(document.querySelector(".rail").scrollWidth - innerWidth),
  ease: "none",
  scrollTrigger: {
    trigger: ".gallery",
    start: "top top",
    end: () => "+=" + document.querySelector(".rail").scrollWidth,
    pin: true,
    scrub: 1,
    invalidateOnRefresh: true,
  },
});

gsap.from(".gallery-item", {
  opacity: 0,
  scale: 0.9,
  stagger: 0.2,
  scrollTrigger: {
    trigger: ".gallery-item",
    containerAnimation: track,
    start: "left 80%",
  },
});

containerAnimation tells ScrollTrigger to watch the rail's animation instead of the scrollbar, since horizontal movement by transform is no real scroll.

Three documented limitations, and all of them bite:

  • The container's animation needs a linear ease (ease: "none").
  • Pin and snap fail in triggers based on containerAnimation.
  • Avoid animating the trigger element itself along the horizontal axis, or compensate in the start and end values.

The invalidateOnRefresh: true on the first trigger recomputes the widths when the window changes size. Without it, rotating the phone breaks the calculation.

5. Reveal lists in batches

Applying one trigger per card across a thirty-item grid works and looks bad: each card animates alone, with no relation to the neighbors that entered at the same instant.

ScrollTrigger.batch() solves it by grouping the elements that fired the same callback inside a time window.

js
ScrollTrigger.batch(".card", {
  start: "top 88%",
  interval: 0.1,
  batchMax: 4,
  onEnter: (elements) =>
    gsap.to(elements, { opacity: 1, y: 0, stagger: 0.15, overwrite: true }),
});

The callback receives two arguments: the array of elements that entered in that window and the corresponding triggers. interval defines the size of the collection window in seconds and batchMax limits how many elements enter per batch, taking a function too for responsive layouts.

batch() accepts the normal ScrollTrigger properties, apart from the animation-related ones such as animation, scrub and snap, and trigger itself. It builds each element's trigger on its own.

6. Snapping between sections

Snap makes the scroll settle at specific positions once the user stops scrolling. Used with moderation, it adds polish. Used too much, it fights the user.

js
ScrollTrigger.create({
  trigger: ".sections",
  start: "top top",
  end: "bottom bottom",
  snap: {
    snapTo: 1 / 4,        // five points: 0, 0.25, 0.5, 0.75, 1
    duration: { min: 0.2, max: 0.6 },
    delay: 0.1,
    ease: "power1.inOut",
    directional: true,
  },
});

snapTo takes a number (increments), an array of values, a function with logic of its own, or the words "labels" and "labelsDirectional" when you want snapping to a timeline's labels. The default for directional is true, meaning the snap respects the direction you were scrolling, and the default ease is "power3".

The duration with {min, max} is the detail separating tolerable snap from irritating snap: it stops a short snap from taking as long as a long one.

7. Progress indicator

A reading bar at the top, a section counter, anything that needs to know how far the scroll has gone. onUpdate fires on every progress change and receives the instance itself.

js
ScrollTrigger.create({
  trigger: "article",
  start: "top top",
  end: "bottom bottom",
  onUpdate: (self) => {
    gsap.set(".progress-bar", { scaleX: self.progress });
  },
});

self.progress runs from 0 to 1. Animating scaleX with transform-origin: left keeps the work on the GPU, unlike animating width, which forces the browser to recompute layout on every frame. The post on CSS scroll-driven with no JS shows that this specific case already has a native CSS solution, with no library at all.

Worth knowing that onToggle also exists, firing only when the active state changes, and onRefresh, when the positions get recomputed. For an indicator, onUpdate is the right one. For toggling a menu class on and off, onToggle spends far less.

8. Responsive and respecting reduced-motion

The last pattern is the one appearing least in tutorials and preventing the most rework.

ScrollTrigger.matchMedia() now carries a deprecation. The replacement is gsap.matchMedia(), which wraps and surpasses the old version, and reverts on its own the animations and ScrollTriggers created inside it.

js
const mm = gsap.matchMedia();

mm.add(
  {
    isDesktop: "(min-width: 900px)",
    isMobile: "(max-width: 899px)",
    reducedMotion: "(prefers-reduced-motion: reduce)",
  },
  (context) => {
    const { isDesktop, reducedMotion } = context.conditions;

    if (reducedMotion) {
      gsap.set(".section", { opacity: 1, y: 0 });
      return;
    }

    gsap.from(".section", {
      y: isDesktop ? 60 : 24,
      opacity: 0,
      scrollTrigger: { trigger: ".section", start: "top 85%" },
    });
  }
);

prefers-reduced-motion: reduce signals that the person turned on, in their operating system, the preference for minimizing non-essential movement. The documented purpose is serving people with vestibular disorders, for whom scaling or displacing large objects provokes real discomfort. The no-preference value evaluates as false and means only that nobody declared a preference.

Serving that costs the six lines in the example. Ignoring it costs a person feeling sick on your site. The full treatment, with the substitution table by effect type, sits in prefers-reduced-motion without killing the design.

The eight side by side, and where CSS already solves it

Half these patterns have a native equivalent today. Checking before installing a library pays off, because a CSS scroll-driven animation runs on the compositor thread and depends on no JavaScript loading.

#PatternCentral propertyCSS equivalent
1Reveal on entrytoggleActions, onceanimation-timeline: view()
2Tie to scrollscrubanimation-timeline: scroll()
3Pin the sectionpin, pinSpacingNone. position: sticky pins, and gives no progress
4Horizontal inside verticalcontainerAnimationNone
5Reveal in batchesScrollTrigger.batch()Partial, with animation-delay by hand
6Snapping between sectionssnapscroll-snap-type, old and wide support
7Progress baronUpdateanimation-timeline: scroll()
8Responsive and accessiblegsap.matchMedia()@media, directly

Patterns 3, 4 and 5 are the real reason to keep GSAP in a project. The other five fit in CSS when the target allows, with the caveat that Firefox turns the support on through a preference alone, covered in detail in the post on CSS scroll-driven.

An example of a project with a pin, a horizontal track and a batch reveal coexisting on the same page sits in the Plantica case, where each decision's cost appears alongside the result.

What breaks inside React

This section exists because most ScrollTrigger examples target loose HTML, and the audience building premium sites today lives in Next.js.

The concrete problem: React's Strict Mode runs the effects twice in development. With no cleanup, you end up with two ScrollTriggers on the same element, fighting over the same measurement.

The @gsap/react package brings the useGSAP() hook, which reverts on its own every animation, ScrollTrigger, Draggable and SplitText created during the run when the component unmounts.

jsx
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

gsap.registerPlugin(ScrollTrigger, useGSAP);

export function Section() {
  const container = useRef(null);

  useGSAP(
    () => {
      gsap.from(".card", {
        y: 40,
        opacity: 0,
        stagger: 0.1,
        scrollTrigger: { trigger: ".card", start: "top 85%" },
      });
    },
    { scope: container }
  );

  return <section ref={container}>{/* ... */}</section>;
}

Two of the hook's options change the behavior. scope takes a ref and limits every text selector to that container's descendants, which stops ".card" from catching cards in another component. dependencies controls when the block runs again, with an empty array by default, and revertOnUpdate decides whether the context reverts when the dependencies change.

One trap remains: an animation created after the hook runs, inside an onClick or a setTimeout, enters no cleanup. For those, wrap the function with contextSafe().

The errors that cost the most time

Five things I have debugged more than once, each with a documented cause.

The animation fires too early. It is the default start of "top bottom". Swap it for a percentage, such as "top 85%".

The content jumps when the pin releases. A container in display: flex drops pinSpacing to false. Either reserve the space by hand, or take the flex off the container under the pin.

The measurement comes out wrong on a page with images. The positions get computed before the images load and change the document's height. Set width and height on the images, or call ScrollTrigger.refresh() after loading.

Everything misaligns after rotating the phone. invalidateOnRefresh: true is missing from the triggers whose start and end values depend on a screen measurement.

The animation is stuck and you have no idea why. Turn markers: true on, see where the lines land, and check whether the trigger element is the one you imagine. Half the time, it is.

To decide whether ScrollTrigger is the right tool before writing any of those lines, the decision tree between GSAP, Motion and pure CSS settles it in three questions. And the complete guide to motion design for the web covers the larger context, from brief to delivery.

When the animations trip over each other

One symptom appears in real testing alone and never in development: the person scrolls fast, passes five sections in one gesture, and the five animations all start at once and finish stacked. The result looks like a bug and the behavior is what the docs describe.

Three properties handle that, and none of them tends to appear in a tutorial.

fastScrollEnd forces the current trigger's animation to complete when the person leaves the trigger area above a certain speed. The default is 2500 pixels per second, and you can demand more, such as fastScrollEnd: 3000, so only a very fast scroll triggers the cut.

preventOverlaps acts the instant a trigger is about to start: it looks for earlier scroll-based animations and forces them to their final state. It takes true, which affects every earlier one, or a string as an identifier, when you want only animations from the same group to cancel each other.

js
gsap.from(".section", {
  y: 60,
  opacity: 0,
  scrollTrigger: {
    trigger: ".section",
    start: "top 85%",
    fastScrollEnd: true,
    preventOverlaps: "reveal",
  },
});

refreshPriority solves another pile-up, the one in the calculation. A trigger with refreshPriority: 1 gets recomputed before one with 0, which is the default. The documentation recommends creating the ScrollTriggers in page order first, top to bottom, and reserving the property for when that proves impossible.

Creation order matters because a pin pushes everything that comes after it. If the lower trigger got created first, it measures a document that still knows nothing of the space the pin will reserve.

Frequently asked questions

Does ScrollTrigger cost money?

No. It was never a premium plugin, and since the licensing change the whole GSAP package ships under the "no charge" license. The detail of who holds the property and which clauses still hold sits in GSAP 100% free: what changed.

What is the difference between scrub: true and scrub: 1?

With true, the animation follows the scrollbar with no lag, and every jolt of the gesture shows. With a number, that number is how long in seconds the playhead takes to catch up to the bar's position, which smooths the reading. Between 0.5 and 1.5 covers most cases.

Why does my pin make the following content jump?

Because pinSpacing is reserving no space. It arrives as true by default and adds padding at the bottom, and it drops to false when the container uses display: flex. In that case, the CSS has to solve the space.

Can snap be used with horizontal scrolling?

Not in triggers based on containerAnimation. The documentation states that pin and snap are unavailable in that mode, because the horizontal movement comes from a transform rather than the scrollbar. The snapping has to be built into the container's animation.

Sources

Verified on 20 August 2026.

Review trigger: revisit when (a) GSAP publishes a major that changes ScrollTrigger's API, (b) containerAnimation starts accepting pin or snap, or (c) useGSAP() changes package or signature.