You have decided on GSAP, or you are weighing it up, and you want things to move as the page scrolls. Text that reveals, an image that stays put while the copy runs past it, a progress bar that tracks how far through an article you are.

All of that is one plugin: ScrollTrigger. It attaches to any GSAP tween or timeline and decides when, and how, scroll position drives it.

This guide covers the whole surface: a copy-paste CDN setup, your first reveal, staggering a group, scrubbed timelines, pinning (including anticipatePin), callbacks, responsive breakpoints, WordPress and the mistakes that cost the most debugging time. Every example is complete code you can paste, and a set of live ScrollTrigger examples sits near the end.

If you arrived wondering whether plain CSS can do this now, it partly can, and there is an honest side-by-side further down: CSS scroll-driven animations vs GSAP ScrollTrigger.

The two kinds of GSAP scroll animation

Almost every question about scroll animation resolves once you know which of these two you want.

Scroll-triggered. The animation fires once when an element reaches a point in the viewport, then plays to completion on its own clock. Scrolling faster does not make it finish faster. This is the standard fade-and-rise reveal.

Scroll-linked. The animation’s progress is tied to scroll position. Scroll down and it advances, scroll up and it reverses, stop and it stops. GSAP calls this scrubbing.

In ScrollTrigger the difference is a single property. Add scrub and a triggered animation becomes a linked one. Everything else about the setup is identical, which is a large part of why the plugin is worth learning once rather than reaching for a different tool per effect.

Setup: the GSAP ScrollTrigger CDN script tags

ScrollTrigger is a separate file from GSAP core, and it has to be registered before use. Every GSAP plugin, ScrollTrigger included, is free and served from the jsDelivr CDN.

Copy-paste CDN setup

Paste this before your closing </body> tag. Core must load first, then the plugin, then your own code:

<!-- GSAP core, then the ScrollTrigger plugin (GSAP 3.15.0) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>

<!-- Your animation code, after both -->
<script>
  gsap.registerPlugin(ScrollTrigger);
  // ScrollTrigger animations go here
</script>

If your scripts live in the <head>, add defer to all three tags (with your own code moved to an external file) so they still run in order once the document has parsed. The order is the part that matters: a plugin loaded before core, or your code loaded before the plugin, throws gsap is not defined or ScrollTrigger is not defined. The tag for every other plugin, and fixes for the rest of the load-order errors, are on the GSAP CDN links page.

npm and bundlers

In a project with a build step (Vite, Next.js, Astro, webpack), install the package and import the plugin from its own path:

npm install gsap
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

Call registerPlugin once, near the entry point of your app, before any tween that uses a scrollTrigger. Registering twice is harmless. Forgetting entirely gives you a silent no-op in production builds where tree shaking has removed the plugin, which is the single most common “my ScrollTrigger does nothing” cause.

Your first scroll reveal

Live, interactive preview of a ScrollTrigger reveal. Scroll inside the frame to drive it. Get the full code →
<div class="reveal">This will fade in</div>
<div class="reveal">So will this</div>
gsap.registerPlugin(ScrollTrigger);

if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  gsap.utils.toArray('.reveal').forEach((el) => {
    gsap.from(el, {
      y: 30,
      opacity: 0,
      duration: 0.6,
      ease: 'power2.out',
      scrollTrigger: {
        trigger: el,
        start: 'top 85%',
      },
    });
  });
}

Two things worth reading closely.

trigger: el gives every element its own ScrollTrigger. If you pass a shared selector string instead, all the elements animate together the moment the first one arrives, which looks broken on a long page. Looping with gsap.utils.toArray is the fix.

start: 'top 85%' reads as “when the top of the trigger reaches 85% down the viewport”. The first value is a point on the element, the second a point on the viewport. 'top bottom' fires the instant the element appears, 'center center' waits until it is halfway up the screen.

Staggering a group

When the elements are siblings that should cascade, you want them on one ScrollTrigger, not one each. That is where GSAP’s stagger replaces the delay arithmetic you would otherwise write by hand.

gsap.from('.card', {
  y: 40,
  opacity: 0,
  duration: 0.6,
  ease: 'power2.out',
  stagger: 0.08,
  scrollTrigger: {
    trigger: '.card-grid',
    start: 'top 80%',
  },
});

The trigger is now the container, and stagger sequences the children. A plain number staggers them in DOM order, which is fine for a row or a list.

For a real grid, add the grid option and the stagger becomes two-dimensional:

stagger: { each: 0.05, grid: 'auto', from: 'center' }

grid: 'auto' makes GSAP measure the rendered elements to work out how many columns there actually are, then stagger by distance from an origin instead of by index. That is what lets from: 'center' radiate outward and from: 'edges' close inward, and it keeps working when a breakpoint reflows four columns into two. from: 'random' is also supported. The Stagger Grid Reveal effect uses this at production scale.

Replaying on scroll back

By default a ScrollTrigger plays once and stays played. toggleActions controls what happens at each of four boundaries, in the order onEnter, onLeave, onEnterBack, onLeaveBack.

scrollTrigger: {
  trigger: '.reveal',
  start: 'top 85%',
  end: 'bottom 15%',
  toggleActions: 'play none none reverse',
}

That plays on the way down and reverses on the way back up. 'play pause resume reset' is the other common pairing. Be sparing: content that re-animates every time it passes the viewport gets irritating quickly on a long page.

Scroll-linked animation with scrub

Live, interactive preview of a scrubbed progress indicator. Scroll inside the frame to drive it. Get the full code →

A reading progress bar is the smallest useful example of scroll-linking.

<div class="progress-bar"></div>
<article class="content">
  <!-- Long content here -->
</article>
.progress-bar {
  position: fixed;
  inset: 0 auto auto 0;
  height: 4px;
  width: 100%;
  transform: scaleX(0);
  transform-origin: 0 50%;
  background: #c8ff00;
  z-index: 100;
}
gsap.to('.progress-bar', {
  scaleX: 1,
  ease: 'none',
  scrollTrigger: {
    trigger: '.content',
    start: 'top top',
    end: 'bottom bottom',
    scrub: true,
  },
});

Note scaleX rather than width. Width forces a layout recalculation on every scroll frame; a transform does not. On a scrubbed animation that runs continuously, this is the difference between smooth and janky on a mid-range phone.

ease: 'none' matters too. Scroll position is already the easing curve, so an ease on top of it makes the bar lag and rush against your actual scrolling.

GSAP timeline with ScrollTrigger

A single tween is fine for one property. As soon as several things should happen in order across the same stretch of scroll, put them on a timeline and give the timeline the ScrollTrigger:

const mm = gsap.matchMedia();

mm.add('(prefers-reduced-motion: no-preference)', () => {
  const tl = gsap.timeline({
    scrollTrigger: {
      trigger: '.feature',
      start: 'top 70%',
      end: 'bottom 30%',
      scrub: 1,
    },
  });

  tl.from('.feature h2', { y: 40, opacity: 0 })
    .from('.feature img', { scale: 0.85, opacity: 0 }, '<0.2')
    .from('.feature p', { y: 20, opacity: 0 }, '-=0.1');
});

The timeline’s total duration gets stretched across the scroll distance between start and end, so durations become proportions rather than seconds. A tween with duration: 2 takes twice as much scroll as one with duration: 1. The position parameter ('<0.2', '-=0.1') overlaps steps exactly as it does in a time-based timeline.

One rule catches almost everyone: put scrollTrigger on the timeline, never on the tweens inside it. A child tween with its own ScrollTrigger fights the parent for control of its playhead, and the result is steps that jump or never finish.

Without scrub, the same timeline becomes a triggered sequence: it plays through on its own clock the moment start is crossed, which is the right choice for an entrance that should not depend on how fast the reader scrolls.

scrub: true versus scrub: 1

scrub: true maps progress exactly to scroll position. scrub: 1 gives the playhead one second to catch up, smoothing out the stepped input from a mouse wheel. For anything with visible detail, a numeric scrub between 0.5 and 1.5 almost always looks better than true. For a progress bar, exact tracking is the point, so true is right.

Scrubbing is not only for bars and parallax. Driving a split-text timeline with it gives you a reading highlight that advances word by word, which the free Scroll Text Highlight effect demonstrates, and pushing it further gets you scroll-linked colour changes across a whole page.

Pinning

Pinning holds an element in place while the page scrolls past it, and gives you a controlled distance to animate across.

gsap.to('.sticky-image', {
  scale: 1.2,
  ease: 'none',
  scrollTrigger: {
    trigger: '.image-section',
    start: 'top top',
    end: 'bottom top',
    scrub: 1,
    pin: '.sticky-image',
  },
});

ScrollTrigger fixes the pinned element and inserts spacer markup so the rest of the document still flows correctly. Two rules save most of the pain:

  • Pin a child, not the trigger, when you can. pin: '.sticky-image' with trigger: '.image-section' is easier to reason about than pinning the section itself.
  • Leave pinSpacing alone unless you have a specific reason. By default ScrollTrigger pads the pin’s spacer by the pin distance, so the next section waits until the pin releases. Turning it off overlaps sections and is the usual cause of “my next section is hidden underneath”.

What anticipatePin does, and when to use it

With native browser scrolling, the page moves before your JavaScript hears about it. ScrollTrigger applies a pin in response to a scroll event, so on a fast flick the element can travel a few pixels past its pin point for a frame and then snap back into place. That one-frame jump is most visible on touch devices, where scrolling runs on its own thread.

anticipatePin tells ScrollTrigger to apply the pin slightly early, by an amount proportional to how fast the user is scrolling, so the element is already fixed when it arrives:

scrollTrigger: {
  trigger: '.chapter',
  start: 'top top',
  end: '+=1500',
  pin: true,
  scrub: 1,
  anticipatePin: 1,
}
  • Use 1 as the value. It is a multiplier on the predicted distance. 1 is the normal setting; higher values pin even earlier, which rarely helps.
  • Add it only when you can see the jump. Test on a real phone with a quick flick. If the pinned element never overshoots, leave the property out.
  • It does not change spacing. anticipatePin only moves the moment the pin engages. pinSpacing still decides how much room the pin takes up in the document, so an overlap or a gap after the pin is a spacing problem, not an anticipation one.
  • Never combine it with a smooth-scroll library. Lenis and similar libraries feed every frame into ScrollTrigger, so there is no gap to cover and the early pin becomes pure error: the section gets yanked upward as it arrives. It is banned across this site’s catalogue for exactly that reason.

Chained pinned sections need care: give each one a reveal that starts from 'top bottom' so the incoming content is already animating as the previous pin releases, or you ship a dead viewport between chapters. Scroll Hijack Sections shows the pattern end to end.

Once pinning clicks, most of the ambitious scroll patterns turn out to be variations on it: stacking cards that lock over one another, a zoom portal that swallows the viewport, and horizontal scroll sections that apply the same pin plus scrub combination sideways.

Callbacks

ScrollTrigger will run your own code at the boundaries, which is what makes it a scroll coordinator rather than only an animation tool.

ScrollTrigger.create({
  trigger: '.chapter-two',
  start: 'top center',
  end: 'bottom center',
  onEnter: () => setActiveNav('two'),
  onEnterBack: () => setActiveNav('two'),
  onUpdate: (self) => {
    document.body.dataset.direction = self.direction === 1 ? 'down' : 'up';
  },
});

ScrollTrigger.create() with no animation attached is a perfectly normal thing to do. Scrollspy navigation, lazy video playback and analytics section tracking are all this shape.

Inside onUpdate the instance gives you self.progress (0 to 1 across the trigger), self.direction (1 down, -1 up) and self.getVelocity(), which returns pixels per second. Note the method name: it is getVelocity(), not velocity. Feeding that velocity into a gsap.quickTo is how speed-reactive effects like Scroll Velocity Skew stay smooth instead of recalculating a tween every frame.

Responsive scroll animations

gsap.matchMedia() creates animations at a breakpoint and reverts them cleanly when it stops matching. That cleanup is the whole point: without it, a desktop pin left over from a resize will fight the mobile layout.

const mm = gsap.matchMedia();

mm.add('(min-width: 768px)', () => {
  gsap.to('.gallery-track', {
    xPercent: -100,
    ease: 'none',
    scrollTrigger: {
      trigger: '.gallery',
      start: 'top top',
      end: '+=2000',
      scrub: 1,
      pin: true,
    },
  });
});

mm.add('(max-width: 767px)', () => {
  gsap.from('.gallery-item', {
    opacity: 0,
    y: 30,
    stagger: 0.08,
    scrollTrigger: { trigger: '.gallery', start: 'top 85%' },
  });
});

Reduced motion belongs in the same mechanism rather than in a separate if, so that a user toggling the preference gets the change without a reload:

mm.add('(prefers-reduced-motion: no-preference)', () => {
  gsap.from('.reveal', {
    opacity: 0,
    y: 30,
    stagger: 0.08,
    scrollTrigger: { trigger: '.reveal-group', start: 'top 85%' },
  });
});

Everything inside that callback simply never runs for a user who has asked for reduced motion, and the content stays in its natural, visible state. Conditions can be combined, so '(min-width: 768px) and (prefers-reduced-motion: no-preference)' is valid and often what you actually want.

Debugging

Turn on markers. markers: true draws labelled lines for the start and end of a trigger. Almost every “it fires too early” report is answered in seconds by looking at where the lines actually landed.

Refresh after late content. ScrollTrigger measures the page once and caches the numbers. Images without width and height attributes, web fonts and injected content all change the height afterwards, which shifts every trigger below them. Give media explicit dimensions where you can, and call ScrollTrigger.refresh() once when you cannot.

Check the scroller. If your animation lives inside a scrolling container rather than the window, ScrollTrigger needs scroller: '.my-container'. An overflow: hidden or overflow: auto ancestor you had forgotten about is a frequent culprit.

Mistakes that cost the most time

The reveal flash. You set opacity: 0 in CSS so elements start hidden, the browser paints them, then ScrollTrigger initialises and the element visibly blinks. Worse, if the JavaScript never runs the content is invisible forever. Hide reveal targets only once JavaScript has confirmed it is running:

.reveal { opacity: 1; }
.has-js .reveal { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  .has-js .reveal { opacity: 1; }
}
document.documentElement.classList.add('has-js');

Content is readable with JavaScript disabled, blocked or simply slow, which is a real scenario on caching-heavy platforms.

Animating layout properties. Stick to transform and opacity. width, height, top, left and margin all trigger layout on every frame, and a scrubbed animation runs every frame you scroll.

Animations that block reading. If a user has to wait for something to finish before they can read it, the animation has stopped being decoration.

Never cleaning up. In a single-page app or a component framework, ScrollTriggers survive navigation unless you kill them. gsap.context() or the useGSAP hook handles this; see GSAP with React for the framework specifics.

ScrollTrigger in WordPress

Everything above works in WordPress unchanged. What differs is how the scripts get onto the page, and that is where most WordPress ScrollTrigger problems start.

Enqueue, do not paste. Load GSAP, ScrollTrigger and your own file through wp_enqueue_script in a child theme’s functions.php (or a small site plugin), and declare the dependencies so WordPress prints them in the right order:

add_action('wp_enqueue_scripts', function () {
  $args = ['in_footer' => true, 'strategy' => 'defer'];

  wp_enqueue_script(
    'gsap',
    'https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js',
    [],
    '3.15.0',
    $args
  );
  wp_enqueue_script(
    'gsap-scrolltrigger',
    'https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js',
    ['gsap'],
    '3.15.0',
    $args
  );
  wp_enqueue_script(
    'site-scroll',
    get_stylesheet_directory_uri() . '/js/scroll.js',
    ['gsap', 'gsap-scrolltrigger'],
    '1.0',
    $args
  );
});

The dependency arrays are what guarantee core, then plugin, then your code. The strategy key needs WordPress 6.3 or later; on older versions pass true as the last argument to load in the footer instead.

Register in your own file. gsap.registerPlugin(ScrollTrigger) goes at the top of scroll.js, not in an inline snippet in a header widget, so it always runs after both libraries.

Watch optimisation plugins. Caching and performance plugins that combine, minify or “delay JavaScript until interaction” can reorder or postpone the GSAP files. If animations only work when you are logged in, or only after the first click, exclude the GSAP URLs and your scroll script from those features.

Page builders. In Elementor, Bricks, Divi or the block editor, add a CSS class to the element through the builder’s own class field and target that class from your script. Builders often lazy-load sections or change height after load, so call ScrollTrigger.refresh() once the page has finished loading if triggers fire in the wrong place.

The GSAP in WordPress guide covers setup in more depth, and these tutorials build complete WordPress examples:

GSAP ScrollTrigger examples

Reading about a pin or a scrubbed timeline only goes so far. These live effects each isolate one ScrollTrigger pattern from this guide, so you can scroll through the result before building your own:

For more, the GSAP scroll animations category collects every scroll-driven effect in one place, and all categories break the rest of the effects library down by technique.

The free ones include their full source. The others are production versions of the same patterns, with responsive layouts, reduced-motion handling and cleanup already done, and plain HTML, CSS and JavaScript you can drop into any site. Buy one on its own, or get every effect, template and UI element in the Vault.

CSS scroll-driven animations vs GSAP ScrollTrigger

“Can I just do this in CSS?” is now a fair question rather than a hopeful one. CSS scroll-driven animations are real, they are shipping, and for a class of jobs they are the better answer. Here is where the line actually sits for scroll work; GSAP vs CSS animations has the full comparison.

What CSS can genuinely do

Two timeline functions do the work. view() tracks an element’s own progress through the viewport, which covers entrance reveals. scroll() tracks progress through a scroll container, which covers progress bars.

A scroll-into-view reveal, with no JavaScript at all:

@keyframes reveal-in {
  from { opacity: 0; transform: translateY(30px); }
  to   { opacity: 1; transform: none; }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: reveal-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 60%;
    }
  }
}

animation-range: entry 0% entry 60% says “run from the moment the element starts entering the viewport until it is 60% of the way through entering”. No duration is needed, because scroll position is the clock. The free CSS Scroll Reveal effect is a working version of this.

Scrubbing works too, which surprises people. The progress bar from earlier in this guide needs no library:

@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

@supports (animation-timeline: scroll()) {
  .progress-bar {
    animation: grow linear both;
    animation-timeline: scroll();
  }
}

Two genuine advantages come with this. There is no library to download, and in supporting browsers these animations run off the main thread, so they keep moving even while JavaScript is busy.

Note the shape of that CSS: everything lives inside @supports, so a browser without scroll timelines leaves the element in its normal visible state. The CSS approach is immune to the reveal-flash problem by construction, because you never set opacity: 0 as a default.

Where the CSS approach stops

Four walls, and you tend to hit them in this order.

No pinning. There is no CSS equivalent of holding a section still while the page scrolls a controlled distance past it. position: sticky looks close but gives you no progress value to animate against and no spacer management. Every pinned narrative in this guide is out of reach.

No sequencing. Each element animates on its own timeline. Building “the heading finishes, then the image scales, then the caption arrives” means hand-tuning animation-range percentages per element and redoing them whenever the copy changes. A GSAP timeline expresses the same thing as an ordered list.

No callbacks. CSS cannot tell your application anything. Scrollspy navigation, lazy video playback and section analytics all need JavaScript regardless, so if you need those you have a scroll library already.

No real staggering. There is no stagger. You approximate it with nth-child rules offsetting animation-range, which is manageable for six cards and unmanageable for a responsive grid whose column count changes at breakpoints.

Browser support, honestly

This is the part that decides it for most production work. Chrome and Edge have supported scroll-driven animations since 115, Safari since 26, and Firefox in 156. That is roughly 85% of global traffic as of this update, and the feature is not yet Baseline.

Whether that is fine depends entirely on the failure mode you choose. Written as above, unsupported browsers get the content with no animation, which is a perfectly good outcome for a decorative reveal. It is not a good outcome when the animation is the content, such as a scroll-driven product walkthrough.

The comparison

FeatureCSS scroll-drivenIntersection ObserverGSAP ScrollTrigger
Triggered revealsYesYesYes
Scroll-linked scrubbingYesNoYes
PinningNoNoYes
Sequenced timelinesNoManualBuilt in
Staggeringnth-child workaroundsManualBuilt in
CallbacksNoEnter and leave onlyFull
Breakpoint handlingMedia queriesManualgsap.matchMedia
Runs off main threadYesN/ANo
Browser supportNot yet BaselineUniversalUniversal
DependencyNoneNoneAbout 46kb gzipped for core plus the plugin

How to choose

Reach for CSS when the animation is decorative entrance work, each element is independent, and you are happy for older browsers to simply show the content. This is a large share of blog and marketing pages, and it is the right call for them.

Reach for ScrollTrigger when you need pinning, when several things have to happen in a defined order, when your JavaScript needs to know where the user is, when a responsive grid has to stagger correctly at every breakpoint, or when the animation is load-bearing enough that “no animation in Firefox” is not an acceptable outcome.

That list describes most production marketing sites, which is why this guide is ScrollTrigger-first. Mixing is normal and encouraged: CSS for the cheap independent reveals, Intersection Observer for lazy loading, ScrollTrigger for the parts that need choreography. GSAP vs CSS animations goes into the wider trade-off.

Conclusion

The mental model is small. Pick a trigger element, say where in the viewport it starts, decide whether the animation runs on its own clock or on scroll position, and add pinning if you need room to work in. Everything else in ScrollTrigger is a refinement of those four decisions.

Build it with markers: true on, animate transforms and opacity, put your breakpoints and your reduced-motion check in gsap.matchMedia, and make sure the page still reads with the JavaScript switched off.

Practise the decisions on a live page

Before adding several scroll effects to a project, make one section answer three questions: can the reader see the content when it starts, do they have enough scroll distance to read it, and what happens when they go back? Test those with the actual copy in place. A short placeholder heading hides timing problems that a real paragraph exposes.

The Scroll animation for developers course turns those decisions into practical labs. You move trigger markers, choose what happens at each crossing, scrub a drawing and arrange chapters on a pinned stage while inspecting the resulting GSAP. It also covers parallax that leaves the words still. The course is free.

If the page still feels unclear before anything moves, start with Visual hierarchy for developers, the free course on reading order, typography, spacing and a clear primary action. It gives you a static page worth animating.


Looking for production-ready GSAP scroll animations? The GSAP scroll animations collection gathers the scroll-triggered reveals, scrubbed sequences, parallax and pinned sections in one place, or browse the full GSAP Vault effects library. For the plugins beyond ScrollTrigger, the GSAP effects guide covers which one to reach for.