9/6/2026 · 12 min read
Scroll animation went mainstream in 2023 and never went back. Today every product landing page, agency site, and startup pitch deck uses scroll to orchestrate reveal, parallax, and narrative — and the gap between a site that does this well and one that does it poorly is the gap between a premium brand and a generic template. GSAP ScrollTrigger is not the only tool that can close that gap, but it is the one with the most predictable behaviour, the deepest browser support, and the most recoverable failure modes.
This article is a practitioner's guide. I am not going to list the ScrollTrigger API in order — the docs do that better than any article can. What I am going to do is explain the mental model behind the five patterns I use on almost every project, the performance mistakes that produce jank even when you think you are doing everything right, and the specific cases where you should stop reaching for GSAP and use the native CSS alternative instead.
The most important thing to understand about ScrollTrigger is that it gives
you a number — a progress value between 0 and 1 that describes how far the user has
scrolled through the trigger region you defined. Everything else follows from that.
When you use scrub: true, you are saying "map this progress value directly
to my animation's playhead." When you use scrub: 1, you are adding a
one-second lag between the scroll position and the playhead — a smoothing function that
gives the animation a physical weight. When you use toggleActions, you are
defining discrete events that fire at specific progress thresholds: enter, leave,
enterBack, leaveBack.
This framing matters because it tells you when to use which approach. Scrubbed animations are for effects that should feel physically tethered to the scroll — parallax layers, value morphs, horizontal panels. Toggle-based animations are for reveal effects that should fire once and complete — a heading fading in, a card sliding up, a number counting to its final value. Mixing the two carelessly produces interactions that feel neither precise nor organic. Choosing between them deliberately is the first design decision in every scroll animation project.
The trigger definition follows the same logic. The trigger
property points to the DOM element that defines the scroll region; start
and end use ScrollTrigger's positional syntax — two values separated by a
space, the first describing the trigger point on the element
(top, center, bottom, or a pixel/percentage
offset), the second describing the corresponding point in the viewport. Once you
internalise this "element position relative to viewport position" model, positioning
becomes intuitive rather than trial-and-error.
The scrubbed reveal is the workhorse of scroll animation. An element starts in a transformed state — moved down, scaled slightly, opacity at zero — and reaches its final resting state as the user scrolls through a defined region. Done right, it feels inevitable. Done wrong, it produces the choppy flicker that makes scroll animations feel cheap.
The key to making it feel right is the scrub value. A boolean
true gives a perfectly mechanical response — the animation advances
exactly as far as the user has scrolled. This is rarely what you want for organic
content reveals because it feels like dragging a window, not watching something
emerge. A value of 0.5 to 1.5 adds inertia: the playhead
trails the scroll position and catches up smoothly. For most content reveals, I use
scrub: 0.8 — enough lag to feel physical, tight enough that fast
scrollers see the animation complete rather than freeze mid-state.
gsap.from(".hero-headline", {
y: 60,
opacity: 0,
duration: 1,
scrollTrigger: {
trigger: ".hero-headline",
start: "top 85%",
end: "top 40%",
scrub: 0.8,
}
});
The start: "top 85%" means the animation begins when the
top of the element reaches 85% down the viewport — just before the user would notice
it consciously. By the time the element's top is at 40% of the viewport, the animation
is complete and the element is sitting in its natural position. This window feels
comfortable. Narrower windows feel rushed; wider windows make the element sit in a
half-animated state for too long.
Parallax is the most abused technique in web animation. The implementation
most developers reach for — moving an element's top or
margin-top in a scroll listener — is also the one guaranteed to produce
jank on any device that can't maintain 60fps while recalculating layout on every
scroll event. GSAP fixes this with its internal RAF loop and CSS transform
optimisation, but there is still a common mistake that trips people up: animating
properties that trigger layout.
The rule is simple: parallax should only ever animate
transform: translateY() — never top, margin,
height, or anything that forces the browser to recalculate layout. GSAP
abstracts this cleanly with the y shorthand, which always compiles to
translateY. Beyond that, mark parallax elements with
will-change: transform in CSS — it hints to the browser to promote the
element to its own compositor layer, so transforms happen entirely on the GPU without
touching the main thread.
gsap.to(".bg-layer", {
y: -120,
ease: "none",
scrollTrigger: {
trigger: ".section",
start: "top bottom",
end: "bottom top",
scrub: true,
}
});
ease: "none" is essential for scrubbed parallax — easing on
a scrubbed animation does not mean what it means on a timed animation. It introduces
a velocity curve relative to scroll progress, which produces an uneven parallax speed
that rarely feels intentional. Linear scroll-to-position relationships are almost
always what you actually want for parallax layers; the "ease" in a parallax effect
comes from the layering and the speed differential, not from a GSAP ease function.
Pinning is ScrollTrigger's most powerful feature and its most dangerous
footgun. The pin: true option fixes an element in place while the user
scrolls through a defined range, and it does this by adding top padding to the
element's next sibling to compensate for the space the pinned element no longer
occupies in the flow. This automatic compensation is helpful in simple layouts and
catastrophic in complex ones — if you have sticky elements, absolute-positioned
children, or anything that depends on accurate layout metrics, ScrollTrigger's padding
injection will break it.
For horizontal scroll panels — the technique where a series of cards
scrolls horizontally while the page scrolls vertically — pin is the correct
foundation. The pattern pins a container at the top of the viewport and scrubs a
horizontal translate across it. The scroll distance needed equals the width of the
content minus one viewport width, so the end value becomes a function
rather than a static string:
const panels = gsap.utils.toArray(".panel");
const totalWidth = panels.length * window.innerWidth;
gsap.to(".panels-wrapper", {
x: () => -(totalWidth - window.innerWidth),
ease: "none",
scrollTrigger: {
trigger: ".horizontal-section",
pin: true,
scrub: 1,
end: () => "+=" + (totalWidth - window.innerWidth),
invalidateOnRefresh: true,
}
});
invalidateOnRefresh: true is not optional here. It tells
ScrollTrigger to re-run the end function whenever the viewport size
changes — without it, the scroll distance calculated on page load will be wrong after
any resize, including the resize that happens when a mobile browser's chrome
appears or disappears as the user scrolls.
When a group of elements should enter in sequence — a row of cards, a list of features, a grid of testimonials — you have two options: a ScrollTrigger on each element that fires individually, or a single ScrollTrigger that controls a timeline containing the entire sequence. The second approach is almost always better. One ScrollTrigger means one scroll listener, one playhead to manage, and a much simpler debugging surface. The stagger is handled by the timeline itself, not by overlapping triggers.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".cards-section",
start: "top 70%",
toggleActions: "play none none reverse",
}
});
tl.from(".card", {
y: 40,
opacity: 0,
duration: 0.6,
stagger: 0.1,
ease: "power2.out",
});
toggleActions: "play none none reverse" means: play forward
on enter, do nothing on leave, do nothing on enterBack, reverse on leaveBack. This
gives the element a proper exit when the user scrolls back up, which most toggle-based
implementations forget about entirely. The result is a section that feels alive in
both scroll directions rather than an animation that fires once and then sits frozen
when revisited.
The most common performance mistake with GSAP ScrollTrigger is not the
animations themselves — it is the DOM state at the moment ScrollTrigger initialises.
ScrollTrigger measures element positions when it sets up. If your page has not
finished loading fonts, images, or lazy-loaded components at that moment, the
measurements will be wrong and your animations will trigger at the incorrect scroll
positions. The fix is to initialise ScrollTrigger inside a window.onload
handler or after a font-ready promise, not inside a DOMContentLoaded
listener that fires before external resources have settled.
The second performance issue is overlapping scroll listeners. Third-party libraries — carousels, sticky headers, lazy loaders — frequently add their own scroll event listeners, and each one runs on the main thread. ScrollTrigger uses a single RAF-based loop internally and is very efficient, but it cannot help you if twenty other listeners are stacking main-thread work on top of it. Audit your scroll listeners before blaming the animation library for jank.
The third issue is animating too many compositor-unfriendly properties
simultaneously. Opacity and transform are compositor-safe — they never force layout or
paint. Filter (blur, brightness) is compositor-safe on
modern browsers but expensive — animating it on large elements will burn through your
performance budget quickly. Never animate border-radius, box-shadow,
or any property that triggers paint on large elements in a scrubbed animation.
Reserve those for toggle-based animations that fire once and complete.
transform and opacity in scrubbed, scroll-tethered animations.will-change: transform sparingly on elements that are actually composited — applying it to everything defeats the purpose.ScrollTrigger.refresh() after any dynamic content loads that changes page height.gsap.matchMedia() to define breakpoint-specific animations rather than fighting media queries after the fact.
CSS scroll-driven animations — the animation-timeline and
scroll() property introduced in Chrome 115 and now shipping in all major
browsers — are genuinely good for a specific class of problem. Progress indicators,
simple fade-ins keyed to viewport entry, parallax on background images: these are all
cases where a few lines of CSS produce a result that is compositor-safe, requires zero
JavaScript, and survives any JS failure mode cleanly.
The cases where you still need GSAP are everything more complex than that.
Multi-element sequenced timelines, scrubbed animations with physical weight, pinned
horizontal scroll sections, animations that need to respond to both scroll position
and other state (user interaction, loaded data, viewport size): CSS has no model for
these. GSAP's ScrollTrigger also has dramatically better debugging tooling — the
markers: true option renders visual start and end lines in the browser,
which is invaluable when an animation triggers half a screen too early on a device
you cannot reproduce locally.
My working heuristic is: if the animation is decorative and self-contained, reach for CSS. If the animation is part of a coordinated storytelling sequence, needs physical weight, or involves pinning, reach for GSAP. The two coexist perfectly — a progress bar in pure CSS at the top of the page, and a pinned horizontal panel section powered by ScrollTrigger below it, is a completely reasonable stack.
The pattern I use most on client projects is a hero section where multiple layers — background, foreground image, headline, subheadline, CTA — each move at different speeds as the user scrolls away from the hero. The effect communicates depth, pulls the user into the scroll journey, and makes the brand feel intentional without a single line of custom physics code. The full implementation using the principles above:
gsap.registerPlugin(ScrollTrigger);
const heroTl = gsap.timeline({
scrollTrigger: {
trigger: ".hero",
start: "top top",
end: "bottom top",
scrub: true,
}
});
heroTl
.to(".hero-bg", { y: 200, ease: "none" }, 0)
.to(".hero-image", { y: 120, ease: "none" }, 0)
.to(".hero-headline", { y: 80, opacity: 0, ease: "none" }, 0)
.to(".hero-sub", { y: 60, opacity: 0, ease: "none" }, 0)
.to(".hero-cta", { y: 40, opacity: 0, ease: "none" }, 0);
The key is the differential: background at 200px, image at 120px, text
layers stepping down from 80 to 40px. The opacity fade on text prevents the awkward
moment where readable text overlaps the next section. All values are in
y — transform only — and the timeline shares a single ScrollTrigger
rather than five separate ones. At position 0 in the timeline, all
tweens start simultaneously and each finishes at the same progress point, so the
entire hero exits in unison when the user scrolls to the next section.
The patterns in this article are not exhaustive. ScrollTrigger has capabilities I have not touched — video synchronisation, snap points, per-element callback granularity — but the five patterns above cover ninety percent of the scroll animations I ship. Start with the mental model, choose your approach before you write a line of code, and test on a real mid-range Android device before you call it done. The simulator lies. The real device always finds the truth.