< Go back

GSAP ScrollTrigger in 2026: Building Premium Scroll-Driven Animations

9/6/2026 · 12 min read

Native CSS scroll-driven animations arrived. GSAP ScrollTrigger is still the tool you reach for when the experience needs to feel like cinema rather than a browser feature.

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 Mental Model: Progress as a Variable

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.

Pattern One: The Scrubbed Reveal

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.

Pattern Two: Parallax Without Jank

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.

Pattern Three: Pinned Sections and Horizontal Scroll

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.

Pattern Four: Staggered Timeline Entrances

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.

Performance: What Actually Causes Jank

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.

  • Initialise ScrollTrigger after fonts and images are loaded — not just after DOM ready.
  • Only animate transform and opacity in scrubbed, scroll-tethered animations.
  • Use will-change: transform sparingly on elements that are actually composited — applying it to everything defeats the purpose.
  • Call ScrollTrigger.refresh() after any dynamic content loads that changes page height.
  • On mobile, consider disabling or simplifying parallax — the performance budget is dramatically smaller and users expect less.
  • Use GSAP's gsap.matchMedia() to define breakpoint-specific animations rather than fighting media queries after the fact.

When to Use Native CSS Scroll-Driven Animations Instead

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.

Putting It Together: A Hero Section Pattern

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.

Conclusion: Scroll Is a Design Tool, Not a Framework Feature

GSAP ScrollTrigger gives you what the browser's native scroll APIs cannot yet give you: precise, debuggable, physically-weighted control over complex multi-element animations tied to scroll progress. The gap between a scroll experience that feels like a polished product and one that feels like a prototype is rarely the library — it is the decision about when to use scrub versus toggle, how to compose parallax layers, and how to avoid the layout-triggering mistakes that produce jank on the devices your clients actually own.

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.

Next article

Designing for Dark Mode →

Need scroll animations built for your product?

Contact me