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: your first reveal, staggering a group, scrubbing, pinning, callbacks, responsive breakpoints and the mistakes that cost the most debugging time. Every example is complete code you can paste.

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

ScrollTrigger is a separate file from GSAP core, and it has to be registered before use.

<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>
gsap.registerPlugin(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.

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. Three 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. Turning it off overlaps sections and is the usual cause of “my next section is hidden underneath”.
  • Do not use anticipatePin with a smooth-scroll library. It predicts the pin moment based on velocity, and when a smooth-scroll library is already interpolating that velocity the prediction fights it and yanks the page. 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.

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.

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.