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

Contents
- The four parameters that explain almost every bug
- 1. Reveal on entering the viewport
- 2. Tie the animation to the scrollbar
- 3. Pin the section while something happens
- 4. Horizontal scrolling inside vertical
- 5. Reveal lists in batches
- 6. Snapping between sections
- 7. Progress indicator
- 8. Responsive and respecting reduced-motion
- The eight side by side, and where CSS already solves it
- What breaks inside React
- The errors that cost the most time
- When the animations trip over each other
- Frequently asked questions
- 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.
| Property | Default | What it means |
|---|---|---|
start | "top bottom", or "top top" when a pin exists | The 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 |
pinSpacing | true, and false when the container is display: flex | Space 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:
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
| # | Pattern | Central property | CSS equivalent |
|---|---|---|---|
| 1 | Reveal on entry | toggleActions, once | animation-timeline: view() |
| 2 | Tie to scroll | scrub | animation-timeline: scroll() |
| 3 | Pin the section | pin, pinSpacing | None. position: sticky pins, and gives no progress |
| 4 | Horizontal inside vertical | containerAnimation | None |
| 5 | Reveal in batches | ScrollTrigger.batch() | Partial, with animation-delay by hand |
| 6 | Snapping between sections | snap | scroll-snap-type, old and wide support |
| 7 | Progress bar | onUpdate | animation-timeline: scroll() |
| 8 | Responsive and accessible | gsap.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.
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.
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
- GSAP — ScrollTrigger: API documentation. Accessed 20 August 2026.
- GSAP — ScrollTrigger.batch()/). Accessed 20 August 2026.
- GSAP — gsap.matchMedia()/). Accessed 20 August 2026.
- GSAP — GSAP with React and the useGSAP() hook. Accessed 20 August 2026.
- MDN Web Docs — prefers-reduced-motion. Accessed 20 August 2026.
- npm — the gsap package registry, version 3.15.0 published 13 April 2026. Accessed 20 August 2026.
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.
Read next
Motion •
Motion Design for the Web: The Complete Guide
Scroll, text, images and video: the complete catalog of motion techniques for the web, with implementation in Next.js and the cases where each one pays off.
- motion
- scroll
The definitive guide — a Next.js site built around motion and scroll
The scroll foundation that, when missing, keeps the animations from working at all: Lenis, GSAP and Next.js wired in the right order and the mistakes to avoid.
- next.js
- lenis
Infra •
Documentation: deploying a Next.js application with GitHub + Hostinger
Every push becomes a live site with no hosting panel involved: connecting GitHub to Hostinger, the build settings that break and the checks after each deploy.
- deploy
- github


