{"id":181,"date":"2026-10-23T06:51:33","date_gmt":"2026-10-23T06:51:33","guid":{"rendered":"https:\/\/webdevpuneet.com\/blog\/?p=181"},"modified":"2026-10-23T06:51:38","modified_gmt":"2026-10-23T06:51:38","slug":"gsap-scrolltrigger-tutorial-animate-elements-on-scroll-9-live-demos","status":"publish","type":"post","link":"https:\/\/webdevpuneet.com\/blog\/gsap-scrolltrigger-tutorial-animate-elements-on-scroll-9-live-demos\/","title":{"rendered":"GSAP ScrollTrigger Tutorial: Animate Elements on Scroll (9 Live Demos)"},"content":{"rendered":"<p><!--\n================================================================\n  POST TITLE (paste into the WordPress title field \u2014 it becomes the page H1)\n  GSAP ScrollTrigger Tutorial: Animate Elements on Scroll (9 Live Demos)\n\n  SEO TITLE (56 chars, target 50\u201360, max 60 \u2014 \"Optimize SEO\" panel \u2192 SEO TITLE)\n  GSAP ScrollTrigger Tutorial: Animate on Scroll (9 Demos)\n\n  SEO DESCRIPTION (156 chars, target 150\u2013160, max 160 \u2014 \"Optimize SEO\" panel \u2192 SEO DESCRIPTION)\n  Learn GSAP ScrollTrigger with 9 live demos: start and end positions, scrub, toggleActions, pinning, horizontal scroll, progress bars, stagger and refresh().\n\n  HOW TO PASTE: click the first line after this comment, press Ctrl+Shift+End, Ctrl+C.\n  In WordPress click \"Type \/ to choose a block\", then Ctrl+V.\n  Fallback: \u22ee menu \u2192 Code editor \u2192 paste.\n================================================================\n--><\/p>\n\n\n<p class=\"wp-block-paragraph\">ScrollTrigger is the plugin that turns GSAP from &#8220;animates on click or on load&#8221; into &#8220;animates in response to where the user actually is on the page&#8221; \u2014 and it&#8217;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 \u2014 what <code>start<\/code> and <code>end<\/code> actually mean, why <code>scrub<\/code> changes everything about how an animation behaves, and why a trigger&#8217;s saved position can quietly go stale the moment the page&#8217;s layout changes underneath it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The 9 demos below are all <strong>live and interactive<\/strong> right here in the article \u2014 scroll inside each preview and watch the behavior happen in real time, not just in a video. Each embed also has a <strong>Fork &amp; Edit<\/strong> button that opens the exact code, already running, in <a href=\"https:\/\/webdevpuneet.com\/ui-snippets\/mycode\/\">My Code<\/a> \u2014 webdevpuneet&#8217;s free in-browser editor. Change a <code>start<\/code> value, flip <code>scrub<\/code> on or off, delete <code>pin: true<\/code> \u2014 that&#8217;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 <a href=\"https:\/\/webdevpuneet.com\/gsap-playground\/\">GSAP Playground<\/a> is webdevpuneet&#8217;s free in-browser course.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The one thing every ScrollTrigger config has in common<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>gsap.registerPlugin(ScrollTrigger); \/\/ once, before any scrollTrigger option is used\n\ngsap.to('.el', {\n  x: 200,\n  scrollTrigger: {\n    trigger: '.el',   \/\/ the element being watched\n    start: 'top 80%', \/\/ when THIS point on the trigger reaches THIS point in the viewport\n    end: 'bottom 20%',\n    scrub: false,      \/\/ false = play once like a normal tween; true\/number = tie to scroll position\n    pin: false,         \/\/ true = lock the trigger in place while start\/end plays out\n    markers: false       \/\/ true in real projects (dev only) \u2014 draws start\/end lines on screen\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every demo below is a variation on this one object. <code>trigger<\/code> is the element ScrollTrigger watches; <code>start<\/code> and <code>end<\/code> are each a pair of positions \u2014 &#8220;this point on the trigger element&#8221; and &#8220;this point in the viewport&#8221; \u2014 written as a string like <code>'top 80%'<\/code> (the trigger&#8217;s top edge reaches 80% down the viewport) or a pixel\/relative offset like <code>'+=300'<\/code>. Everything else \u2014 <code>scrub<\/code>, <code>pin<\/code>, <code>toggleActions<\/code>, the <code>onEnter<\/code>\/<code>onLeave<\/code> callbacks \u2014 controls what happens once the scroll position falls between those two points. <code>markers: true<\/code> 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&#8217;t render meaningfully inside a small embedded iframe, so several demos below build an on-screen readout that shows the same underlying numbers instead.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. gsap.from() With a scrollTrigger Option<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll down inside the preview. No click, no hover \u2014 the card animates in purely because it crossed a scroll position.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/1.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger First Taste Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gsap.registerPlugin(ScrollTrigger);\n\ngsap.from('.reveal', {\n  opacity: 0,\n  y: 50,\n  duration: 0.8,\n  ease: 'power2.out',\n  scrollTrigger: {\n    trigger: '.reveal',\n    start: 'top 80%'\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is the same <code>gsap.from()<\/code> from GSAP&#8217;s core tweening API, unchanged \u2014 it still animates <em>from<\/em> the values you give it back to the element&#8217;s real CSS. The only new part is the <code>scrollTrigger<\/code> object replacing &#8220;play immediately.&#8221; <code>trigger: '.reveal'<\/code> tells ScrollTrigger which element&#8217;s position to watch, and <code>start: 'top 80%'<\/code> means &#8220;fire the moment this element&#8217;s top edge reaches 80% down the viewport&#8221; \u2014 leaving <code>end<\/code> unset is fine here because this tween isn&#8217;t scrubbed or pinned, it just needs one moment to fire at. Under the hood, ScrollTrigger attaches a scroll listener, converts the trigger element&#8217;s position into a pixel range using <code>getBoundingClientRect()<\/code>, and calls <code>.play()<\/code> on the tween the instant the scroll position crosses into that range. Everything else in this post is a variation on exactly that mechanism.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> the single most common scroll-triggered pattern on real websites \u2014 a section fading and sliding into place the moment it enters view. <strong>Tip:<\/strong> reach for <code>gsap.from()<\/code> here specifically because the destination values are already sitting in the element&#8217;s normal CSS \u2014 you never have to hand-write where it should end up.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. start \/ end Position Strings, Made Visible<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll slowly. The green line marks exactly where <code>start: 'top 80%'<\/code> sits in this preview&#8217;s viewport \u2014 watch the readout flip from inactive to active the instant the target crosses it.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/2.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger start end Position Strings Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>scrollTrigger: {\n  trigger: '.target',\n  start: 'top 80%',  \/\/ trigger's TOP edge reaches 80% down the viewport\n  end: 'top 40%',     \/\/ trigger's TOP edge reaches 40% down the viewport\n  onUpdate: function (self) {\n    console.log(self.progress, self.isActive);\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A position string always has the same shape: <code>'{edge of the trigger} {point in the viewport}'<\/code>. <code>'top 80%'<\/code> pins the trigger&#8217;s own top edge against a horizontal line drawn 80% of the way down the viewport \u2014 that&#8217;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. <code>self.progress<\/code> (0 at <code>start<\/code>, 1 at <code>end<\/code>) and <code>self.isActive<\/code> (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 (<code>start: 200<\/code>) and relative offsets (<code>end: '+=300'<\/code>) are also valid and behave the same way once you know which axis they measure.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> building the actual mental model behind every other demo on this page \u2014 once <code>'top 80%'<\/code> clicks, every other combination of edges and percentages follows the same logic. <strong>Tip:<\/strong> in a real project, set <code>markers: true<\/code> during development instead of building a custom readout \u2014 it draws these exact lines directly on your page, with the actual pixel values labeled, and you strip it out before shipping.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. scrub: true Ties Progress to Scroll Position<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll down slowly, then scroll back up. The dot doesn&#8217;t play once \u2014 it tracks your scroll position directly, forward and backward.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/3.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger scrub true Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gsap.to('.dot', {\n  x: 260,\n  ease: 'none',   \/\/ let scroll position supply the easing, not the tween\n  scrollTrigger: {\n    trigger: '.track',\n    start: 'top 80%',\n    end: 'bottom 30%',\n    scrub: true    \/\/ or a number of seconds, for a slight catch-up delay\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Without <code>scrub<\/code>, a scroll-triggered tween behaves like any other tween: it plays once, on its own timeline, at its own <code>duration<\/code>, the moment <code>start<\/code> is crossed \u2014 scrolling faster or slower afterward has zero effect on it. <code>scrub: true<\/code> throws that timeline out entirely and replaces it with a direct link: the tween&#8217;s playhead is set to exactly <code>self.progress<\/code>, every single scroll event, with no <code>duration<\/code> involved at all. That&#8217;s why <code>ease: 'none'<\/code> matters here \u2014 the &#8220;easing&#8221; 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 <code>true<\/code> (like <code>scrub: 0.5<\/code>) 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> any animation that should feel like it&#8217;s physically attached to the scrollbar \u2014 parallax, scroll-scrubbed illustrations, progress indicators, horizontal scroll sections. <strong>Tip:<\/strong> a scrubbed tween&#8217;s own <code>duration<\/code> is ignored entirely once <code>scrub<\/code> is set \u2014 the scroll distance between <code>start<\/code> and <code>end<\/code> is what actually controls how &#8220;long&#8221; the animation feels.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. toggleActions Controls Play \/ Pause \/ Resume \/ Reverse<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll the box past both edges of the preview, then scroll back up through it. Watch the log at the top \u2014 four distinct events, one per direction crossed.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/4.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger toggleActions Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>scrollTrigger: {\n  trigger: '.box',\n  start: 'top 80%',\n  end: 'bottom 20%',\n  toggleActions: 'play pause resume reverse',\n  \/\/ order: onEnter, onLeave, onEnterBack, onLeaveBack\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>toggleActions<\/code> takes exactly four space-separated keywords, mapped in order to the four ways a scroll position can cross a trigger&#8217;s boundaries: scrolling down past <code>start<\/code> (<code>onEnter<\/code>), scrolling down past <code>end<\/code> (<code>onLeave<\/code>), scrolling back up past <code>end<\/code> (<code>onEnterBack<\/code>), and scrolling back up past <code>start<\/code> (<code>onLeaveBack<\/code>). Each keyword names what should happen to the tween at that moment \u2014 common choices are <code>play<\/code>, <code>pause<\/code>, <code>resume<\/code>, <code>reverse<\/code>, <code>restart<\/code>, and <code>none<\/code>. The default, <code>'play none none none'<\/code>, is why a plain <code>scrollTrigger<\/code> tween (like demo #1) only ever plays forward once and then does nothing on the way back up. <code>'play pause resume reverse'<\/code>, used here, is the classic &#8220;fully responsive to scroll direction&#8221; 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 (<code>onEnter<\/code>, <code>onLeave<\/code>, <code>onEnterBack<\/code>, <code>onLeaveBack<\/code>) still fires alongside whatever action runs, which is what populates this demo&#8217;s log.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> any element that should respond naturally to scrolling in both directions, not just play once on the way down. <strong>Tip:<\/strong> if an animation looks fine scrolling down but glitches or snaps oddly scrolling back up, <code>toggleActions<\/code> is almost always the first thing to check \u2014 the default leaves the three &#8220;scroll back up&#8221; cases doing nothing at all.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. pin: true Locks a Section in Place<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll down. The card locks onto the screen and stays there while you keep scrolling \u2014 it only lets go once the pinned distance runs out.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/5.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger pin true Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ScrollTrigger.create({\n  trigger: '.pinSection',\n  start: 'top top',\n  end: '+=300',   \/\/ pinned for 300px of additional scrolling\n  pin: true,\n  scrub: true,\n  onUpdate: function (self) {\n    \/\/ self.progress tracks exactly how far through the pinned distance you are\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>pin: true<\/code> takes the trigger element out of normal document flow the instant <code>start<\/code> is reached and fixes it in place on screen \u2014 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. <code>end: '+=300'<\/code> defines exactly how much of that scroll distance the pin lasts for: 300px worth of scrolling, measured from wherever <code>start<\/code> fired. Combined with <code>scrub: true<\/code>, this is the actual pattern behind almost every &#8220;scroll-driven story&#8221; section on the web \u2014 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&#8217;s fill bar is just <code>self.progress<\/code> from the pinned trigger, written straight to a width percentage.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> a section that needs the reader&#8217;s full attention for a beat \u2014 a stat reveal, a step-by-step explainer, a hero image that reacts as you scroll past it. <strong>Tip:<\/strong> pinning changes page layout (that inserted spacer), which is exactly the kind of change that can leave <em>other<\/em> triggers further down the page stale \u2014 see demo #9 for why, and when to call <code>ScrollTrigger.refresh()<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">6. A Horizontal-Scroll Section<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll down normally. Instead of the page moving up, the panels below slide sideways \u2014 ordinary vertical scroll input, redirected into horizontal motion.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/6.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger Horizontal Scroll Section Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gsap.to('.hRow', {\n  x: function () { return -(hRow.scrollWidth - hSection.clientWidth); },\n  ease: 'none',\n  scrollTrigger: {\n    trigger: '.hSection',\n    start: 'top top',\n    end: function () { return '+=' + (hRow.scrollWidth - hSection.clientWidth); },\n    scrub: true,\n    pin: true\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is demos #3 and #5 combined into one recognizable pattern: <code>pin: true<\/code> holds the horizontal-scroll section on screen exactly like demo #5, while <code>scrub: true<\/code> ties a horizontal <code>x<\/code> tween to that same held-in-place scroll distance exactly like demo #3. The only new idea is the math \u2014 the row of panels is wider than its container, so <code>x<\/code> animates from <code>0<\/code> to negative the overflow amount (<code>scrollWidth<\/code> minus <code>clientWidth<\/code>), and <code>end<\/code> 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 \u2014 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&#8217;s just that the section responds by moving its contents sideways instead of scrolling itself off-screen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> portfolio strips, image galleries, and step-by-step feature tours that read more naturally left-to-right than as a long vertical stack. <strong>Tip:<\/strong> horizontal-scroll sections are one of the trickiest ScrollTrigger patterns to get right on real content because the scroll distance depends on the content&#8217;s actual rendered width \u2014 always compute it, never hard-code it, exactly as this demo does.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">7. A Scroll Progress Bar<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll down through all four sections. The thin bar at the top fills in exact proportion to how far through the whole preview you&#8217;ve scrolled.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/7.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger Progress Bar Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ScrollTrigger.create({\n  trigger: document.body,\n  start: 'top top',\n  end: 'bottom bottom',\n  onUpdate: function (self) {\n    progressBar.style.width = (self.progress * 100) + '%';\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This demo skips animating any tween at all \u2014 there&#8217;s no <code>gsap.to()<\/code> here, just a bare <code>ScrollTrigger.create()<\/code> whose only job is reporting position through its <code>onUpdate<\/code> callback. Setting <code>trigger<\/code> to the whole page (or, on a real site, whatever scrollable container matters) with <code>start: 'top top'<\/code> and <code>end: 'bottom bottom'<\/code> means <code>self.progress<\/code> tracks the entire document&#8217;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 \u2014 and because it&#8217;s just a number being read on every scroll event, that same <code>self.progress<\/code> value can drive anything else just as easily: a percentage counter, a table-of-contents highlight, a &#8220;back to top&#8221; button&#8217;s visibility.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> 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. <strong>Tip:<\/strong> <code>ScrollTrigger.create()<\/code> without any tween attached is a completely valid, common pattern \u2014 use it whenever you need the position data itself more than you need something to visibly animate.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">8. Staggered Card Reveal on Scroll<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Scroll down to the grid. One <code>scrollTrigger<\/code>, attached once to the whole grid \u2014 and all six cards reveal in a cascading sequence anyway.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/8.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger Staggered Card Reveal Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>gsap.from('.card', {\n  opacity: 0,\n  y: 40,\n  duration: 0.5,\n  stagger: 0.12,\n  scrollTrigger: {\n    trigger: '.cardGrid',\n    start: 'top 85%'\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This works because <code>stagger<\/code> and <code>scrollTrigger<\/code> are two entirely independent GSAP features that happen to compose cleanly: <code>gsap.from('.card', ...)<\/code> targets every element matching <code>.card<\/code> in one call, <code>stagger: 0.12<\/code> offsets each of their individual start times by 0.12 seconds, and the <code>scrollTrigger<\/code> object decides only <em>when the whole group&#8217;s clock starts<\/em> \u2014 the moment the grid (not any individual card) crosses <code>start<\/code>. ScrollTrigger doesn&#8217;t need to know or care that it&#8217;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&#8217;s a useful thing to internalize generally: ScrollTrigger only ever triggers a timeline or tween&#8217;s playback, and everything that timeline or tween already knows how to do \u2014 stagger, ease, repeat, sequencing \u2014 keeps working completely unchanged underneath it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> 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. <strong>Tip:<\/strong> trigger the group itself (the grid container), not each card individually \u2014 triggering each card separately would fire each one exactly when it crosses <code>start<\/code> on its own, which defeats the whole point of a coordinated stagger.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">9. ScrollTrigger.refresh() and Stale Trigger Positions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Insert block above&#8221; first, then read the number. Only after clicking &#8220;ScrollTrigger.refresh()&#8221; does the trigger&#8217;s saved start position catch up to reality.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/19\/9.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"ScrollTrigger refresh Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The rule:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var st = ScrollTrigger.create({ trigger: '.target', start: 'top 80%' });\n\n\/\/ st.start is a PIXEL NUMBER, calculated once and cached \u2014 not recalculated\n\/\/ automatically just because the page's layout changed underneath it.\n\ninsertContentAbove(); \/\/ st.start is now wrong\n\nScrollTrigger.refresh(); \/\/ recalculates every trigger's start\/end against current layout<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The first time ScrollTrigger sees a trigger, it converts a position string like <code>'top 80%'<\/code> 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&#8217;s normally invisible \u2014 ScrollTrigger already listens for window resize and automatically refreshes on its own. What it can&#8217;t know about on its own is layout changing for a reason that isn&#8217;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 <code>ScrollTrigger.refresh()<\/code> to remeasure. This demo makes that gap deliberately visible: clicking &#8220;Insert block above&#8221; changes the document&#8217;s real height immediately, but <code>st.start<\/code> \u2014 read directly and shown in the readout \u2014 doesn&#8217;t move until refresh is explicitly triggered.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> diagnosing &#8220;my ScrollTrigger fires at the wrong scroll position&#8221; bugs, which are very often a stale measurement rather than a wrong <code>start<\/code> value. <strong>Tip:<\/strong> call <code>ScrollTrigger.refresh()<\/code> right after any code that changes layout outside of a window resize \u2014 after images\/fonts finish loading, after content is injected or removed, or after any animation that changes an element&#8217;s height while <em>not<\/em> itself being driven by ScrollTrigger.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common pitfalls<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Forgetting <code>gsap.registerPlugin(ScrollTrigger)<\/code>.<\/strong> ScrollTrigger ships as a separate script from core GSAP and has to be registered once before any <code>scrollTrigger<\/code> option does anything at all \u2014 skip it and the option is silently ignored, no error thrown.<\/li>\n\n\n\n<li><strong>Setting a tween&#8217;s own <code>duration<\/code> on a scrubbed animation and expecting it to matter.<\/strong> Demo #3&#8217;s rule explains why \u2014 once <code>scrub<\/code> is set, the scroll distance between <code>start<\/code> and <code>end<\/code> is what controls pacing, not the tween&#8217;s <code>duration<\/code>.<\/li>\n\n\n\n<li><strong>Never touching <code>toggleActions<\/code> and being confused why scrolling back up does nothing.<\/strong> The default is <code>'play none none none'<\/code> \u2014 demo #4 shows what actually changes once all four states are handled deliberately.<\/li>\n\n\n\n<li><strong>Hard-coding a horizontal or pinned scroll distance in pixels.<\/strong> 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.<\/li>\n\n\n\n<li><strong>Changing page layout with JavaScript and never calling <code>ScrollTrigger.refresh()<\/code>.<\/strong> Demo #9 is the direct proof \u2014 a trigger&#8217;s start\/end positions are cached numbers, not something ScrollTrigger continuously re-measures on its own.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need a separate CDN script for ScrollTrigger, or does it come with GSAP core?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Separate script, every time. Core GSAP (<code>gsap.min.js<\/code>) doesn&#8217;t include any plugins \u2014 ScrollTrigger loads from its own file (<code>ScrollTrigger.min.js<\/code>), as shown in the CDN panel on every demo above, and still needs <code>gsap.registerPlugin(ScrollTrigger)<\/code> called once before it can be used.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What does markers: true actually do, and why don&#8217;t these demos use it?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In a real browser, <code>markers: true<\/code> draws labeled horizontal lines directly on the page at each trigger&#8217;s actual <code>start<\/code> and <code>end<\/code> positions \u2014 it&#8217;s the fastest way to debug &#8220;why isn&#8217;t this firing where I expect.&#8221; It renders relative to the real page and doesn&#8217;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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why did my pinned section&#8217;s layout break other ScrollTrigger animations further down the page?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>pin: true<\/code> inserts spacer space into the document to preserve total scroll distance, which changes every element&#8217;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 \u2014 see demo #9&#8217;s exact mechanism and call <code>ScrollTrigger.refresh()<\/code> after the change settles.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use ScrollTrigger with a horizontally scrolling container instead of the page itself?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes \u2014 ScrollTrigger accepts a <code>scroller<\/code> option pointing at any scrollable element instead of the default window, along with a matching <code>horizontal: true<\/code> 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 <code>start<\/code>\/<code>end<\/code>\/<code>scrub<\/code>\/<code>pin<\/code> options all carry over unchanged once <code>scroller<\/code> is set.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is scrub: true expensive for performance compared to a normal triggered tween?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">It runs on every scroll event rather than once, but ScrollTrigger&#8217;s scroll handling is already heavily optimized (throttled to the browser&#8217;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 <code>width<\/code> or <code>top<\/code>) instead of transform-based ones (<code>x<\/code>, <code>y<\/code>, <code>scale<\/code>) \u2014 the tweens above stick to transforms for exactly that reason. The two progress fills (demos #5 and #7) write a width directly from <code>onUpdate<\/code>, which is fine for one thin bar; for anything heavier, animate <code>scaleX<\/code> with <code>transform-origin: left<\/code> instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every demo above ships its full HTML, CSS and JS in the <strong>HTML \/ CSS \/ JS<\/strong> tabs in its own top bar. The fastest way to actually lock this in is to click <strong>Fork &amp; Edit<\/strong> on demo #4, change <code>toggleActions<\/code> back to the default <code>'play none none none'<\/code>, and watch scrolling back up through the box do nothing at all \u2014 then restore it and watch the reverse action kick back in. That single experiment explains more than any diagram. Once you&#8217;ve got a version you like, save it to <a href=\"https:\/\/webdevpuneet.com\/ui-snippets\/mycode\/\">My Code<\/a> and it&#8217;s yours to keep, tweak further, or drop into a real project.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Recommended next step:<\/strong> open the <a href=\"https:\/\/webdevpuneet.com\/gsap-playground\/\">GSAP Playground<\/a> and rebuild demo #5&#8217;s pinned section from scratch against a section of your own \u2014 typing it out yourself, not just reading it, is what makes <code>start<\/code>\/<code>end<\/code>\/<code>scrub<\/code>\/<code>pin<\/code> actually stick together as one mental model. For more tutorials like this one, browse the <a href=\"https:\/\/webdevpuneet.com\/blog\/category\/gsap\/\">GSAP<\/a> category on the blog.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>ScrollTrigger is the plugin that turns GSAP from &#8220;animates on click or on load&#8221; into &#8220;animates in response to where the user actually is on the page&#8221; \u2014 and it&#8217;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 [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":182,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_et_pb_use_builder":"","_et_pb_old_content":"","_et_gb_content_width":"","advanced_seo_description":"Learn GSAP ScrollTrigger with 9 live demos: start and end positions, scrub, toggleActions, pinning, horizontal scroll, progress bars, stagger and refresh().","jetpack_seo_html_title":"GSAP ScrollTrigger Tutorial: Animate on Scroll (9 Demos)","jetpack_seo_noindex":false,"jetpack_seo_schema_type":"","_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"Want to create smooth scroll-based animations with GSAP?\n\nGSAP ScrollTrigger lets you animate elements as users scroll through your website, creating engaging interactions without complicated code.\n\nIn this tutorial, explore 9 live demos covering:\n\n1. Start and end positions\n2. Scrub animations linked to scrolling\n3. toggleActions explained\n4. Pinning elements during scroll\n5. Horizontal scrolling animations\n6. Animated progress bars\n7. Staggered element animations\n8. Refreshing ScrollTrigger calculations\n9. Combining scroll triggers with GSAP timelines\n\nEach demo helps you understand how ScrollTrigger works and how to use it in real-world projects.\n\nWhether you're building interactive landing pages, animated portfolios, or modern web experiences, these techniques will help you take your animations to the next level.\n\nExplore the tutorial and experiment with the live demos.\n\nRead the full tutorial: \n\n#GSAP #JavaScript #WebDevelopment #CSS\n","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":false,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2},"jetpack_post_was_ever_published":false},"categories":[15,9],"tags":[],"class_list":["post-181","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-gsap","category-tutorials","et-has-post-format-content","et_post_format-et-post-format-standard"],"jetpack_publicize_connections":[],"jetpack_sharing_enabled":true,"jetpack_likes_enabled":true,"jetpack_shortlink":"https:\/\/wp.me\/phrh5V-2V","jetpack_featured_media_url":"https:\/\/i0.wp.com\/webdevpuneet.com\/blog\/wp-content\/uploads\/2026\/10\/gsap-scrolltrigger-tutorial-1200x630-1.webp?fit=1200%2C630&ssl=1","_links":{"self":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/181","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/comments?post=181"}],"version-history":[{"count":1,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/181\/revisions"}],"predecessor-version":[{"id":183,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/181\/revisions\/183"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/media\/182"}],"wp:attachment":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/media?parent=181"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/categories?post=181"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/tags?post=181"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}