ScrollTrigger is the plugin that turns GSAP from “animates on click or on load” into “animates in response to where the user actually is on the page” — and it’s the single most-requested GSAP feature for a reason: fade-ins on scroll, pinned sections, horizontal scroll stories, and scroll-scrubbed progress bars all build on the exact same handful of options. The syntax is small. What trips people up almost every time is the mental model — what start and end actually mean, why scrub changes everything about how an animation behaves, and why a trigger’s saved position can quietly go stale the moment the page’s layout changes underneath it.

The 9 demos below are all live and interactive right here in the article — scroll inside each preview and watch the behavior happen in real time, not just in a video. Each embed also has a Fork & Edit button that opens the exact code, already running, in My Code — webdevpuneet’s free in-browser editor. Change a start value, flip scrub on or off, delete pin: true — that’s a far faster way to build real intuition than reading about it. For a slower, lesson-by-lesson path through GSAP from the very beginning, including a full ScrollTrigger chapter, the GSAP Playground is webdevpuneet’s free in-browser course.

The one thing every ScrollTrigger config has in common

gsap.registerPlugin(ScrollTrigger); // once, before any scrollTrigger option is used

gsap.to('.el', {
  x: 200,
  scrollTrigger: {
    trigger: '.el',   // the element being watched
    start: 'top 80%', // when THIS point on the trigger reaches THIS point in the viewport
    end: 'bottom 20%',
    scrub: false,      // false = play once like a normal tween; true/number = tie to scroll position
    pin: false,         // true = lock the trigger in place while start/end plays out
    markers: false       // true in real projects (dev only) — draws start/end lines on screen
  }
});

Every demo below is a variation on this one object. trigger is the element ScrollTrigger watches; start and end are each a pair of positions — “this point on the trigger element” and “this point in the viewport” — written as a string like 'top 80%' (the trigger’s top edge reaches 80% down the viewport) or a pixel/relative offset like '+=300'. Everything else — scrub, pin, toggleActions, the onEnter/onLeave callbacks — controls what happens once the scroll position falls between those two points. markers: true is the real debugging tool for all of this (it draws the actual start/end lines directly on your page in a real browser), but it can’t render meaningfully inside a small embedded iframe, so several demos below build an on-screen readout that shows the same underlying numbers instead.

1. gsap.from() With a scrollTrigger Option

Scroll down inside the preview. No click, no hover — the card animates in purely because it crossed a scroll position.

The rule:

gsap.registerPlugin(ScrollTrigger);

gsap.from('.reveal', {
  opacity: 0,
  y: 50,
  duration: 0.8,
  ease: 'power2.out',
  scrollTrigger: {
    trigger: '.reveal',
    start: 'top 80%'
  }
});

This is the same gsap.from() from GSAP’s core tweening API, unchanged — it still animates from the values you give it back to the element’s real CSS. The only new part is the scrollTrigger object replacing “play immediately.” trigger: '.reveal' tells ScrollTrigger which element’s position to watch, and start: 'top 80%' means “fire the moment this element’s top edge reaches 80% down the viewport” — leaving end unset is fine here because this tween isn’t scrubbed or pinned, it just needs one moment to fire at. Under the hood, ScrollTrigger attaches a scroll listener, converts the trigger element’s position into a pixel range using getBoundingClientRect(), and calls .play() on the tween the instant the scroll position crosses into that range. Everything else in this post is a variation on exactly that mechanism.

Best for: the single most common scroll-triggered pattern on real websites — a section fading and sliding into place the moment it enters view. Tip: reach for gsap.from() here specifically because the destination values are already sitting in the element’s normal CSS — you never have to hand-write where it should end up.

2. start / end Position Strings, Made Visible

Scroll slowly. The green line marks exactly where start: 'top 80%' sits in this preview’s viewport — watch the readout flip from inactive to active the instant the target crosses it.

The rule:

scrollTrigger: {
  trigger: '.target',
  start: 'top 80%',  // trigger's TOP edge reaches 80% down the viewport
  end: 'top 40%',     // trigger's TOP edge reaches 40% down the viewport
  onUpdate: function (self) {
    console.log(self.progress, self.isActive);
  }
}

A position string always has the same shape: '{edge of the trigger} {point in the viewport}'. 'top 80%' pins the trigger’s own top edge against a horizontal line drawn 80% of the way down the viewport — that’s the fixed green line in this demo, drawn independently in CSS purely to make the abstraction visible. The target only starts animating once its real top edge, moving upward as you scroll, crosses that same line. self.progress (0 at start, 1 at end) and self.isActive (true only while the scroll position sits between them) are the two values almost every callback and readout in ScrollTrigger cares about, and this demo exposes both directly instead of hiding them behind an animation. Percentages are relative to the viewport height and reflow automatically on resize; pixel values (start: 200) and relative offsets (end: '+=300') are also valid and behave the same way once you know which axis they measure.

Best for: building the actual mental model behind every other demo on this page — once 'top 80%' clicks, every other combination of edges and percentages follows the same logic. Tip: in a real project, set markers: true during development instead of building a custom readout — it draws these exact lines directly on your page, with the actual pixel values labeled, and you strip it out before shipping.

3. scrub: true Ties Progress to Scroll Position

Scroll down slowly, then scroll back up. The dot doesn’t play once — it tracks your scroll position directly, forward and backward.

The rule:

gsap.to('.dot', {
  x: 260,
  ease: 'none',   // let scroll position supply the easing, not the tween
  scrollTrigger: {
    trigger: '.track',
    start: 'top 80%',
    end: 'bottom 30%',
    scrub: true    // or a number of seconds, for a slight catch-up delay
  }
});

Without scrub, a scroll-triggered tween behaves like any other tween: it plays once, on its own timeline, at its own duration, the moment start is crossed — scrolling faster or slower afterward has zero effect on it. scrub: true throws that timeline out entirely and replaces it with a direct link: the tween’s playhead is set to exactly self.progress, every single scroll event, with no duration involved at all. That’s why ease: 'none' matters here — the “easing” you actually feel now comes entirely from how the user scrolls, and a tween-level ease would just fight it. Passing a number instead of true (like scrub: 0.5) keeps the same link but adds a short elastic catch-up delay, which reads as smoother on a fast trackpad flick without losing the two-way binding. This is the mechanism behind almost every scroll-scrubbed effect on real websites, including the progress bar in demo #7 and the pinned section in demo #5.

Best for: any animation that should feel like it’s physically attached to the scrollbar — parallax, scroll-scrubbed illustrations, progress indicators, horizontal scroll sections. Tip: a scrubbed tween’s own duration is ignored entirely once scrub is set — the scroll distance between start and end is what actually controls how “long” the animation feels.

4. toggleActions Controls Play / Pause / Resume / Reverse

Scroll the box past both edges of the preview, then scroll back up through it. Watch the log at the top — four distinct events, one per direction crossed.

The rule:

scrollTrigger: {
  trigger: '.box',
  start: 'top 80%',
  end: 'bottom 20%',
  toggleActions: 'play pause resume reverse',
  // order: onEnter, onLeave, onEnterBack, onLeaveBack
}

toggleActions takes exactly four space-separated keywords, mapped in order to the four ways a scroll position can cross a trigger’s boundaries: scrolling down past start (onEnter), scrolling down past end (onLeave), scrolling back up past end (onEnterBack), and scrolling back up past start (onLeaveBack). Each keyword names what should happen to the tween at that moment — common choices are play, pause, resume, reverse, restart, and none. The default, 'play none none none', is why a plain scrollTrigger tween (like demo #1) only ever plays forward once and then does nothing on the way back up. 'play pause resume reverse', used here, is the classic “fully responsive to scroll direction” setup: it plays forward on the way down, pauses mid-animation if you stop and leave, resumes from wherever it paused if you re-enter, and genuinely reverses back to the start if you scroll back up past the beginning. Every corresponding callback (onEnter, onLeave, onEnterBack, onLeaveBack) still fires alongside whatever action runs, which is what populates this demo’s log.

Best for: any element that should respond naturally to scrolling in both directions, not just play once on the way down. Tip: if an animation looks fine scrolling down but glitches or snaps oddly scrolling back up, toggleActions is almost always the first thing to check — the default leaves the three “scroll back up” cases doing nothing at all.

5. pin: true Locks a Section in Place

Scroll down. The card locks onto the screen and stays there while you keep scrolling — it only lets go once the pinned distance runs out.

The rule:

ScrollTrigger.create({
  trigger: '.pinSection',
  start: 'top top',
  end: '+=300',   // pinned for 300px of additional scrolling
  pin: true,
  scrub: true,
  onUpdate: function (self) {
    // self.progress tracks exactly how far through the pinned distance you are
  }
});

pin: true takes the trigger element out of normal document flow the instant start is reached and fixes it in place on screen — but critically, it also inserts an equivalent amount of empty space into the page so the total scrollable distance stays the same, and your scroll position keeps advancing normally underneath the pinned element. end: '+=300' defines exactly how much of that scroll distance the pin lasts for: 300px worth of scrolling, measured from wherever start fired. Combined with scrub: true, this is the actual pattern behind almost every “scroll-driven story” section on the web — a pinned hero that counts up a number, fills a chart, or steps through several states, all synced to a scroll distance the visitor controls entirely themselves, rather than a fixed duration. This demo’s fill bar is just self.progress from the pinned trigger, written straight to a width percentage.

Best for: a section that needs the reader’s full attention for a beat — a stat reveal, a step-by-step explainer, a hero image that reacts as you scroll past it. Tip: pinning changes page layout (that inserted spacer), which is exactly the kind of change that can leave other triggers further down the page stale — see demo #9 for why, and when to call ScrollTrigger.refresh().

6. A Horizontal-Scroll Section

Scroll down normally. Instead of the page moving up, the panels below slide sideways — ordinary vertical scroll input, redirected into horizontal motion.

The rule:

gsap.to('.hRow', {
  x: function () { return -(hRow.scrollWidth - hSection.clientWidth); },
  ease: 'none',
  scrollTrigger: {
    trigger: '.hSection',
    start: 'top top',
    end: function () { return '+=' + (hRow.scrollWidth - hSection.clientWidth); },
    scrub: true,
    pin: true
  }
});

This is demos #3 and #5 combined into one recognizable pattern: pin: true holds the horizontal-scroll section on screen exactly like demo #5, while scrub: true ties a horizontal x tween to that same held-in-place scroll distance exactly like demo #3. The only new idea is the math — the row of panels is wider than its container, so x animates from 0 to negative the overflow amount (scrollWidth minus clientWidth), and end is set to that exact same distance in pixels, so the scroll input and the horizontal motion always run out at precisely the same moment. Both values are computed as functions rather than fixed numbers specifically so they stay correct if the panel content or container size ever changes — ScrollTrigger re-evaluates function-based values on refresh. The vertical scroll never actually stops; the visitor is still scrolling down the page the entire time, it’s just that the section responds by moving its contents sideways instead of scrolling itself off-screen.

Best for: portfolio strips, image galleries, and step-by-step feature tours that read more naturally left-to-right than as a long vertical stack. Tip: horizontal-scroll sections are one of the trickiest ScrollTrigger patterns to get right on real content because the scroll distance depends on the content’s actual rendered width — always compute it, never hard-code it, exactly as this demo does.

7. A Scroll Progress Bar

Scroll down through all four sections. The thin bar at the top fills in exact proportion to how far through the whole preview you’ve scrolled.

The rule:

ScrollTrigger.create({
  trigger: document.body,
  start: 'top top',
  end: 'bottom bottom',
  onUpdate: function (self) {
    progressBar.style.width = (self.progress * 100) + '%';
  }
});

This demo skips animating any tween at all — there’s no gsap.to() here, just a bare ScrollTrigger.create() whose only job is reporting position through its onUpdate callback. Setting trigger to the whole page (or, on a real site, whatever scrollable container matters) with start: 'top top' and end: 'bottom bottom' means self.progress tracks the entire document’s scroll position, from 0 at the very top to 1 at the very bottom, and nothing in between needs any math of your own. This is the exact technique behind reading-progress bars on blogs and documentation sites — and because it’s just a number being read on every scroll event, that same self.progress value can drive anything else just as easily: a percentage counter, a table-of-contents highlight, a “back to top” button’s visibility.

Best for: reading-progress indicators on long articles or docs, and as a template for any UI that needs a live 0-to-1 readout of scroll position rather than a triggered animation. Tip: ScrollTrigger.create() without any tween attached is a completely valid, common pattern — use it whenever you need the position data itself more than you need something to visibly animate.

8. Staggered Card Reveal on Scroll

Scroll down to the grid. One scrollTrigger, attached once to the whole grid — and all six cards reveal in a cascading sequence anyway.

The rule:

gsap.from('.card', {
  opacity: 0,
  y: 40,
  duration: 0.5,
  stagger: 0.12,
  scrollTrigger: {
    trigger: '.cardGrid',
    start: 'top 85%'
  }
});

This works because stagger and scrollTrigger are two entirely independent GSAP features that happen to compose cleanly: gsap.from('.card', ...) targets every element matching .card in one call, stagger: 0.12 offsets each of their individual start times by 0.12 seconds, and the scrollTrigger object decides only when the whole group’s clock starts — the moment the grid (not any individual card) crosses start. ScrollTrigger doesn’t need to know or care that it’s driving six separate targets instead of one; from its perspective this is exactly the same single tween as demo #1, just with more elements inside it. That’s a useful thing to internalize generally: ScrollTrigger only ever triggers a timeline or tween’s playback, and everything that timeline or tween already knows how to do — stagger, ease, repeat, sequencing — keeps working completely unchanged underneath it.

Best for: a grid of cards, a list of features, or a row of icons that should reveal as a group the moment a visitor scrolls to them, rather than all snapping in simultaneously. Tip: trigger the group itself (the grid container), not each card individually — triggering each card separately would fire each one exactly when it crosses start on its own, which defeats the whole point of a coordinated stagger.

9. ScrollTrigger.refresh() and Stale Trigger Positions

Click “Insert block above” first, then read the number. Only after clicking “ScrollTrigger.refresh()” does the trigger’s saved start position catch up to reality.

The rule:

var st = ScrollTrigger.create({ trigger: '.target', start: 'top 80%' });

// st.start is a PIXEL NUMBER, calculated once and cached — not recalculated
// automatically just because the page's layout changed underneath it.

insertContentAbove(); // st.start is now wrong

ScrollTrigger.refresh(); // recalculates every trigger's start/end against current layout

The first time ScrollTrigger sees a trigger, it converts a position string like 'top 80%' into an actual pixel number by measuring the page as it exists right then, and it caches that number rather than re-measuring it on every scroll event, for performance. That’s normally invisible — ScrollTrigger already listens for window resize and automatically refreshes on its own. What it can’t know about on its own is layout changing for a reason that isn’t a resize: content inserted or removed by JavaScript, an image finishing its load and pushing everything below it down, a font swap changing text height, an accordion opening. In every one of those cases, the cached pixel number silently stops matching where the trigger visually is, until something calls ScrollTrigger.refresh() to remeasure. This demo makes that gap deliberately visible: clicking “Insert block above” changes the document’s real height immediately, but st.start — read directly and shown in the readout — doesn’t move until refresh is explicitly triggered.

Best for: diagnosing “my ScrollTrigger fires at the wrong scroll position” bugs, which are very often a stale measurement rather than a wrong start value. Tip: call ScrollTrigger.refresh() right after any code that changes layout outside of a window resize — after images/fonts finish loading, after content is injected or removed, or after any animation that changes an element’s height while not itself being driven by ScrollTrigger.

Common pitfalls

  • Forgetting gsap.registerPlugin(ScrollTrigger). ScrollTrigger ships as a separate script from core GSAP and has to be registered once before any scrollTrigger option does anything at all — skip it and the option is silently ignored, no error thrown.
  • Setting a tween’s own duration on a scrubbed animation and expecting it to matter. Demo #3’s rule explains why — once scrub is set, the scroll distance between start and end is what controls pacing, not the tween’s duration.
  • Never touching toggleActions and being confused why scrolling back up does nothing. The default is 'play none none none' — demo #4 shows what actually changes once all four states are handled deliberately.
  • Hard-coding a horizontal or pinned scroll distance in pixels. Demo #6 computes its distance from the actual rendered content width every time, specifically because a fixed number breaks the moment content, fonts, or viewport size changes.
  • Changing page layout with JavaScript and never calling ScrollTrigger.refresh(). Demo #9 is the direct proof — a trigger’s start/end positions are cached numbers, not something ScrollTrigger continuously re-measures on its own.

Frequently asked questions

Do I need a separate CDN script for ScrollTrigger, or does it come with GSAP core?

Separate script, every time. Core GSAP (gsap.min.js) doesn’t include any plugins — ScrollTrigger loads from its own file (ScrollTrigger.min.js), as shown in the CDN panel on every demo above, and still needs gsap.registerPlugin(ScrollTrigger) called once before it can be used.

What does markers: true actually do, and why don’t these demos use it?

In a real browser, markers: true draws labeled horizontal lines directly on the page at each trigger’s actual start and end positions — it’s the fastest way to debug “why isn’t this firing where I expect.” It renders relative to the real page and doesn’t translate meaningfully into a small embedded iframe, which is why several demos above (especially #2 and #9) build an on-screen readout instead, showing the same underlying numbers markers would reveal.

Why did my pinned section’s layout break other ScrollTrigger animations further down the page?

pin: true inserts spacer space into the document to preserve total scroll distance, which changes every element’s position below it. ScrollTrigger handles this automatically for pins it creates itself, but any other layout-changing code you run afterward can still leave other triggers stale — see demo #9’s exact mechanism and call ScrollTrigger.refresh() after the change settles.

Can I use ScrollTrigger with a horizontally scrolling container instead of the page itself?

Yes — ScrollTrigger accepts a scroller option pointing at any scrollable element instead of the default window, along with a matching horizontal: true flag if that container scrolls sideways rather than vertically. Every demo on this page uses the default window/page scroller for simplicity, but the same start/end/scrub/pin options all carry over unchanged once scroller is set.

Is scrub: true expensive for performance compared to a normal triggered tween?

It runs on every scroll event rather than once, but ScrollTrigger’s scroll handling is already heavily optimized (throttled to the browser’s paint cycle, not raw scroll events), so a scrubbed tween on a reasonable number of elements is not something to avoid on performance grounds alone. Where performance actually suffers is animating layout-triggering CSS properties (like width or top) instead of transform-based ones (x, y, scale) — the tweens above stick to transforms for exactly that reason. The two progress fills (demos #5 and #7) write a width directly from onUpdate, which is fine for one thin bar; for anything heavier, animate scaleX with transform-origin: left instead.

Every demo above ships its full HTML, CSS and JS in the HTML / CSS / JS tabs in its own top bar. The fastest way to actually lock this in is to click Fork & Edit on demo #4, change toggleActions back to the default 'play none none none', and watch scrolling back up through the box do nothing at all — then restore it and watch the reverse action kick back in. That single experiment explains more than any diagram. Once you’ve got a version you like, save it to My Code and it’s yours to keep, tweak further, or drop into a real project.

Recommended next step: open the GSAP Playground and rebuild demo #5’s pinned section from scratch against a section of your own — typing it out yourself, not just reading it, is what makes start/end/scrub/pin actually stick together as one mental model. For more tutorials like this one, browse the GSAP category on the blog.


Discover more from WebDevPuneet

Subscribe to get the latest posts sent to your email.