Skip to content
GSAP ScrollTrigger Explained — Complete Guide to Scroll-Driven Animations

GSAP ScrollTrigger Explained — Complete Guide to Scroll-Driven Animations

DodaTech Updated Jun 6, 2026 11 min read
    %%{init: {"flowchart": {"htmlLabels": true}} }%%
flowchart LR
  A["Getting Started"] --> B["Timelines & Sequencing"]
  B --> C["You Are Here: ScrollTrigger"]
  C --> D["Easing & Motion Paths"]
  C --> E["Performance & Best Practices"]
  click A "/frontend/libraries/gsap/gsap-getting-started"
  click B "/frontend/libraries/gsap/gsap-timelines-sequencing"
  click C "/frontend/libraries/gsap/gsap-scrolltrigger"
  click D "/frontend/libraries/gsap/gsap-easing-motion"
  click E "/frontend/libraries/gsap/gsap-performance"
  

GSAP ScrollTrigger links animation progress directly to the scroll position — creating fade-ins, parallax layers, pinned sections, and scrub-tied effects without writing a single scroll event listener.

In this tutorial, you’ll learn how ScrollTrigger transforms a static page into a narrative experience, with practical examples you can use in your own projects. These are the same techniques that power the onboarding flows in Doda Browser and the scan result transitions in Durga Antivirus Pro.

What You’ll Learn

  • Install and register ScrollTrigger via CDN or npm
  • Create basic scroll-triggered fade-in animations
  • Use ScrollTrigger.create() for full control
  • Master start/end position values
  • Pin elements in place during scroll
  • Scrub animations so they follow the scrollbar bidirectionally
  • Build parallax depth effects
  • Create horizontal scrolling sections
  • Make animations responsive with matchMedia()
  • Debug with markers and refresh properly

Why ScrollTrigger Matters

Before ScrollTrigger, scroll-linked animations meant writing manual scroll event listeners, calculating positions with getBoundingClientRect(), and managing throttling. It was error-prone and hard to maintain.

Security note: Understanding Gsap Scrolltrigger helps build more secure applications — a core principle at DodaTech, where tools like Durga Antivirus Pro and Doda Browser rely on solid implementation practices.

ScrollTrigger does all that work for you. You declare what should happen when, and the plugin handles the math. This means:

  • Better performance — uses GSAP’s ticker, not raw scroll events
  • Precise control — millisecond-accurate trigger points
  • Bidirectional scrubbing — scroll forward = play, scroll back = reverse
  • Pinning — lock elements in place during animation

Think of the scrolling dashboard in DodaZIP’s file manager — sections pin, progress bars fill, cards fade in. All powered by ScrollTrigger.

Prerequisites

You should be familiar with GSAP and timelines. ScrollTrigger is a plugin that extends GSAP — it doesn’t replace the core concepts.

Installation — Adding ScrollTrigger

CDN

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script>
  gsap.registerPlugin(ScrollTrigger); // ← MUST call this
</script>

npm

npm install gsap
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger); // ← MUST call this

Critical: If you forget gsap.registerPlugin(ScrollTrigger), the plugin silently fails. Nothing works and there’s no error. Always check this first when debugging.

Basic ScrollTrigger — Fade In on Scroll

The simplest use case: animate an element when it scrolls into view.

gsap.from(".card", {
  scrollTrigger: {
    trigger: ".card",   // the element that triggers the animation
    start: "top 80%",   // when .card's top hits 80% from viewport top
    end: "top 30%",
    toggleActions: "play none none none" // play once on enter
  },
  y: 60,
  opacity: 0,
  duration: 0.8
});

What happens step by step:

  1. The user scrolls down
  2. When .card’s top edge reaches 80% of the viewport height, GSAP starts the animation
  3. The card slides up 60px and fades in over 0.8 seconds
  4. Since toggleActions is "play none none none", it plays once and ignores further scroll

ScrollTrigger.create() — Full Control

For advanced configurations, use ScrollTrigger.create(). This is the verbose form that exposes every option.

ScrollTrigger.create({
  trigger: ".section",
  start: "top center",
  end: "bottom center",
  onEnter: () => console.log("Entered"),
  onLeave: () => console.log("Left"),
  onEnterBack: () => console.log("Entered from below"),
  onLeaveBack: () => console.log("Left from above")
});

When to use this: When you need callbacks, markers, or complex pinning without a paired tween.

Start & End Values — The Position System

The start and end values use the format "{trigger-edge} {viewport-edge}".

// Trigger when element's top hits 80% from viewport top
start: "top 80%"

// End when element's bottom hits 20% from viewport top
end: "bottom 20%"

// Absolute pixel offsets
start: "top -=100px"

// Relative to trigger's own height
start: "top+=100px"

// Keyword defaults
start: "top bottom"   // element top at viewport bottom (default)
end: "bottom top"     // element bottom at viewport top
Edge ValueMeaning
topTop of the element or viewport
bottomBottom of the element or viewport
centerCenter (50%)
80%80% from the viewport top
+=100px / -=100pxOffset in pixels

You might be wondering: Why is the default start: "top bottom"? Because the most common trigger is “element’s top appears at the bottom of the viewport” — right when you scroll it into view.

Pinning — Locking Elements in Place

Pin an element in place while scroll passes through a range. The element stays fixed until the end is reached, then scrolls away naturally.

ScrollTrigger.create({
  trigger: ".pin-section",
  start: "top top",    // when section top hits viewport top
  end: "+=500",        // 500px of scrolling later
  pin: true,
  pinSpacing: true     // adds padding to prevent layout jumps
});

How it works:

  1. User scrolls until .pin-section’s top reaches the viewport top
  2. ScrollTrigger locks the section in place (adds position: fixed)
  3. pinSpacing: true adds a spacer div to prevent the rest of the page from snapping up
  4. After 500px of scrolling, the section unpins and flows away

Why pinSpacing matters: Without it, when the pinned element becomes fixed, the rest of the page collapses upward. With it, the page maintains its natural height.

Scrubbing — Animation Follows the Scrollbar

Scrub ties animation progress directly to the scrollbar. Scroll forward = animation plays forward. Scroll back = animation rewinds.

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: ".progress-section",
    start: "top center",
    end: "bottom center",
    scrub: true        // instant sync
    // scrub: 1        // smooth sync with 1s lag
  }
});

tl.to(".progress-bar", { width: "100%", duration: 1 });

scrub: true vs scrub: 1:

  • true = instant sync. The animation jumps as you scroll.
  • 1 (or any number) = smooth interpolation with that many seconds of lag. Feels more polished.

When scrub is enabled, the tween’s duration is ignored — scroll position fully controls progress.

Toggle Actions — Four-Phase Control

toggleActions controls what happens at each scroll boundary. It’s a 4-word string:

toggleActions: "play pause resume reset"
PositionPhaseMeaning
1stonEnterElement scrolls into view
2ndonLeaveElement scrolls past view
3rdonEnterBackElement re-enters from below
4thonLeaveBackElement leaves upward

Available actions: play, pause, resume, reset, restart, complete, reverse, none.

// Play once when entering, ignore everything else
toggleActions: "play none none none"

// Play forward on enter, reverse on leave
toggleActions: "play none none reverse"

// Restart every time it enters view
toggleActions: "restart none none none"

Markers — Debugging Made Visible

Always enable markers: true during development. They show colored guide lines in the viewport.

ScrollTrigger.create({
  trigger: ".debug-section",
  start: "top 80%",
  end: "bottom 20%",
  markers: true,
  onEnter: () => { /* your code */ }
});
  • Green line = start point
  • Red line = end point
  • Element outline = the trigger element

Important: Remove markers before deploying to production. They create visual noise and a minor performance cost.

Parallax — Creating Depth

Parallax moves elements at different speeds while scrolling, creating an illusion of depth.

gsap.to(".parallax-layer", {
  y: (i, el) => -parseFloat(el.getAttribute("data-speed")) * 200,
  ease: "none",
  scrollTrigger: {
    trigger: ".parallax-section",
    start: "top bottom",
    end: "bottom top",
    scrub: true
  }
});

A cleaner approach with data attributes:

<div class="parallax-layer" data-speed="0.3"></div>
<div class="parallax-layer" data-speed="0.6"></div>
gsap.utils.toArray(".parallax-layer").forEach(layer => {
  const speed = parseFloat(layer.getAttribute("data-speed")) || 0.2;
  gsap.to(layer, {
    y: () => window.innerHeight * speed,
    ease: "none",
    scrollTrigger: {
      trigger: layer.parentElement,
      start: "top bottom",
      end: "bottom top",
      scrub: true
    }
  });
});

Why parallax matters: Doda Browser’s new tab page uses subtle parallax on background patterns — it adds depth without distracting from content.

Horizontal Scrolling

Create a horizontal scroll section by translating panels sideways while pinning the container.

const sections = gsap.utils.toArray(".panel");

gsap.to(sections, {
  xPercent: -100 * (sections.length - 1),
  ease: "none",
  scrollTrigger: {
    trigger: ".horizontal-container",
    pin: true,
    scrub: 1,
    start: "top top",
    end: () => `+=${document.querySelector(".horizontal-container").scrollWidth}`,
    invalidateOnRefresh: true
  }
});

Each .panel should have width: 100vw and be laid out in a horizontal flex row.

Responsive with matchMedia()

Different devices need different animations. ScrollTrigger.matchMedia() handles breakpoint-specific code.

ScrollTrigger.matchMedia({
  // Desktop (≥1024px)
  "(min-width: 1024px)": function() {
    gsap.to(".card", {
      x: 200,
      scrollTrigger: { trigger: ".card", scrub: true }
    });
  },
  // Tablet (768px - 1023px)
  "(min-width: 768px) and (max-width: 1023px)": function() {
    gsap.to(".card", {
      y: 100,
      scrollTrigger: { trigger: ".card", scrub: true }
    });
  },
  // Mobile (<768px) — no animation
  "(max-width: 767px)": function() {
    // Return nothing (or cleanup function)
  }
});

Any ScrollTrigger instances created inside are automatically reverted when the media query no longer matches.

Refreshing — Recalculating After Layout Changes

Images, dynamic content, and font loads change element positions. Refresh to recalculate.

window.addEventListener("load", () => ScrollTrigger.refresh());

// After dynamic content
function addContent() {
  container.innerHTML = newHTML;
  ScrollTrigger.refresh();
}

Use invalidateOnRefresh: true on individual triggers that depend on element dimensions.

Common Mistakes

1. Forgetting to register ScrollTrigger

It silently fails. Always call gsap.registerPlugin(ScrollTrigger) once.

import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger); // required

2. Reversed start/end syntax

The format is "{trigger-edge} {viewport-edge}". The wrong order causes weird behavior.

// Wrong
start: "80% top"

// Correct
start: "top 80%"

3. Animating the trigger element itself without proper pinning

If the trigger is moving (e.g., translating Y) and also pinned, the pin can override the animation. Use a wrapper as trigger.

// Problematic: trigger is also the animated target
gsap.to(".moving-card", {
  y: 100,
  scrollTrigger: { trigger: ".moving-card", pin: true }
});

// Fixed: wrapper triggers, inner animates
gsap.to(".moving-card-inner", {
  y: 100,
  scrollTrigger: { trigger: ".moving-card-wrapper", pin: true }
});

4. Not refreshing after layout changes

Images loading, dynamic content, or font swaps change positions. Without refresh, start/end points are stale.

5. Using scrub: true with non-zero duration on the tween

When scrub is on, the tween’s duration is ignored — scroll position fully controls progress. Remove duration or understand it doesn’t matter.

// duration is meaningless when scrub is true
gsap.to(".el", { x: 200, duration: 5, scrollTrigger: { scrub: true } });

6. Leaving markers in production

Markers create visual noise and a tiny performance cost. Strip them before deploying.

7. Not cleaning up ScrollTrigger in SPAs

Call ScrollTrigger.getAll().forEach(st => st.kill()) when destroying a component. Otherwise, triggers keep listening.

Practice Questions

  1. What is the difference between scrub: true and toggleActions: "play reverse play reverse"?

    scrub: true ties animation progress 1:1 to the scrollbar — forward = forward, back = backward. toggleActions plays the full animation when entering and reverses when leaving — not frame-accurate.

  2. Why does pinSpacing: true matter?

    Without it, when an element is pinned (becomes position: fixed), the page collapses upward because the element is removed from the document flow. pinSpacing inserts a spacer to maintain natural page height.

  3. What happens if you forget gsap.registerPlugin(ScrollTrigger)?

    ScrollTrigger silently fails. No errors, no warnings, no animations. It’s the #1 debugging issue.

  4. When would you use ScrollTrigger.matchMedia()?

    When your scroll animation needs different behavior on desktop vs mobile. For example, parallax on desktop, no animation on mobile.

  5. Why should you call ScrollTrigger.refresh() after images load?

    Images change element heights and positions. Without refresh, start/end trigger points are calculated with wrong dimensions.

Challenge

Build a “product reveal” page with three sections: (1) a hero with parallax background, (2) pinned feature cards that animate in sequence as you scroll, and (3) a scrub-tied progress bar. Use matchMedia to disable the parallax on mobile.

Solution Outline
ScrollTrigger.matchMedia({
  "(min-width: 768px)": function() {
    // Parallax hero
    gsap.to(".hero-bg", {
      y: 150, ease: "none",
      scrollTrigger: { trigger: ".hero", start: "top bottom", end: "bottom top", scrub: true }
    });
  },
  "(max-width: 767px)": function() {
    // No parallax on mobile
  }
});

// Pinned cards with stagger
const tl = gsap.timeline({
  scrollTrigger: { trigger: ".features", pin: true, start: "top top", end: "+=800", scrub: 1 }
});
tl.from(".feature-card", { y: 100, opacity: 0, stagger: 0.2 });

// Progress bar
gsap.to(".progress", {
  width: "100%", ease: "none",
  scrollTrigger: { trigger: "body", start: "top top", end: "bottom bottom", scrub: true }
});

FAQ

What is the difference between scrub and toggleActions?
scrub ties animation progress directly to the scrollbar — forward scroll animates forward, backward scroll rewinds. toggleActions defines discrete actions (play/pause/reverse) at trigger boundaries without a 1:1 scroll-to-progress mapping.
Can I use ScrollTrigger without a timeline?
Yes. Pass a scrollTrigger config directly to gsap.to(), gsap.from(), or gsap.fromTo(). A timeline is only needed for multi-step sequences.
How do I make an animation play only once when the user scrolls past it?
Use toggleActions: "play none none none" with once: true in the ScrollTrigger config. Or use gsap.from() with scrollTrigger.
Do I need to clean up ScrollTrigger instances?
Yes. Call ScrollTrigger.getAll().forEach(st => st.kill()) when destroying a component in a SPA, or return a cleanup function inside matchMedia().
Why is my pinned element jumping?
pinSpacing: true (default) adds margin to prevent layout collapse. If the pinned element has complex sizing, set pinSpacing: false and handle spacing manually, or call ScrollTrigger.refresh() after layout settles.
What does invalidateOnRefresh: true do?
It forces ScrollTrigger to recalculate start/end values every time ScrollTrigger.refresh() is called — essential when element dimensions change dynamically.

Try It Yourself — ScrollStory

Build a multi-section scrolling narrative with pinned sections, fade-in cards, parallax backgrounds, a progress bar, and scrub-tied animations. This is similar to the scroll-driven onboarding flow in Doda Browser.

▶ Try It Yourself Edit the code and click Run

What’s Next

Now that you can build scroll-driven narratives, refine your motion quality and performance.

TutorialWhat You’ll Learn
https://tutorials.dodatech.com/frontend/libraries/gsap/gsap-easing-motion/Natural easing and motion paths
https://tutorials.dodatech.com/frontend/libraries/gsap/gsap-performance/Keeping animations at 60fps
https://tutorials.dodatech.com/frontend/libraries/gsap/gsap-timelines-sequencing/Sequencing multiple animations

Related topics: CSS Scroll Animations, JavaScript Events, React + GSAP

What’s Next

Congratulations on completing this Gsap Scrolltrigger tutorial! Here’s where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro