Under the Hood
Gsap

ScrollTrigger under the hood

ScrollTrigger is the ticker's whole idea run on a different clock — instead of elapsed time driving an animation's playhead, scroll position does, converted into a normalized progress across a start/end range that either gets mapped straight onto a timeline or used to fire callbacks at boundaries, with pinning as the one mechanism it adds on top to hold a section still while its scroll range plays out.

ScrollTrigger under the hood

The first lesson in this module made a point of calling out that "seek to 40% done" is just setting a tween's progress directly, and that a scrubber slider does exactly that. ScrollTrigger is what happens when you take that same operation and swap the thing driving progress from a slider's input event to the page's scroll offset. Nothing about the tween or timeline underneath changes. What changes is what counts as the clock.

That reframing is the entire lesson: once you see ScrollTrigger as "the ticker's job, but the input is scroll position instead of elapsed time," its behavior — scrubbing, pinning, snapping, toggleActions — stops being a pile of separate scroll-animation features and becomes a small set of consequences of computing one number correctly.

The core computation: turning scroll offset into progress

Every ScrollTrigger is built around a trigger element and two boundaries, start and end, each expressed as a pair like "top 80%" — read as "the trigger's top edge, hits 80% down the viewport." Both start and end are eventually resolved to a concrete scroll offset: an absolute number of pixels the page has to have scrolled to, computed from the trigger element's position on the page (getBoundingClientRect(), adjusted for the current scroll), the viewport's height, and the arithmetic implied by the string. "top 80%" and "bottom top" are just two different formulas over the same three inputs — the element's box, the viewport, and the current scroll — for landing on a pixel number.

Once start and end are both resolved to scroll offsets, computing progress on any given frame is one line of arithmetic, structurally identical to the tween's own progress = elapsed / duration:

progress = (currentScroll - start) / (end - start)

clamped to 01. At currentScroll === start, progress is 0. At currentScroll === end, progress is 1. Scroll halfway between the two and progress reads 0.5. This is the same normalized 0-to-1 number the very first lesson built a whole tween around — it's just being fed by window.scrollY (or a scroll container's scrollTop) instead of a dt accumulator.

scrub: mapping progress straight onto a timeline's playhead

With scrub: true, ScrollTrigger takes the progress number from the formula above and calls, in effect, timeline.progress(progress) on every scroll-triggered update. That is exactly the "seek to X%" operation from the first lesson in this module, and exactly the scrubbable-timeline idea the timelines lesson describes when a parent hands a child a position without either needing to agree on absolute seconds. The only thing ScrollTrigger changes is who calls progress() and what number they pass — instead of a slider's input handler reading event.target.value, it's a scroll listener reading the resolved scroll-offset formula.

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: ".panel",
    start: "top 80%",
    end: "bottom top",
    scrub: true,
  },
});

tl.to(".panel .title", { x: 200 })
  .to(".panel .subtitle", { opacity: 1 }, "<0.2");

Scroll up and down inside the trigger's range and the timeline's playhead moves forward and backward in lockstep — because it's the same underlying progress() call the scrubber slider example used, just driven by a different input.

scrub: true maps progress with zero smoothing — the timeline's position is exactly the scroll formula's output, every single update, which can feel a little mechanical on a fast, jittery scroll. scrub: 1 (a number instead of true) changes that: instead of snapping the timeline directly to the target progress, ScrollTrigger lerps the timeline's current progress toward the target progress by a fraction each tick — the number is roughly how many seconds of catch-up lag to apply. This is the exact same lerp arithmetic from the first lesson (value = start + (end - start) * progress), just re-applied every tick to progress itself: the timeline doesn't jump straight to where scroll currently says it should be, it eases toward that target, and because "every tick" means the ticker is still the thing driving the update loop, a numeric scrub is really a small tween whose target is a constantly-moving number instead of a fixed one.

Non-scrub: firing callbacks at the boundaries instead

Without scrub, ScrollTrigger doesn't touch the timeline's progress() continuously at all. Instead it watches for progress crossing 0 and 1 — the trigger entering and leaving its range — and fires callbacks at those crossings: onEnter, onLeave, onEnterBack, onLeaveBack. toggleActions is a compact way to declare what those four callbacks should do (typically play, pause, resume, reverse, or restart on the underlying timeline) without writing four separate functions:

gsap.timeline({
  scrollTrigger: {
    trigger: ".card",
    start: "top 85%",
    toggleActions: "play none none reverse",
    // enter forward: play. leave forward: none.
    // enter backward: none. leave backward: reverse.
  },
}).from(".card", { opacity: 0, y: 40 });

Same underlying progress computation as scrub mode — the boundary crossings it's watching for are just progress === 0 and progress === 1 on the same 01 number — it's only the response to progress that differs: continuously re-seeking a playhead versus firing a one-shot action at each crossing.

Pinning: freezing an element while its range scrolls past

Pinning holds a trigger element visually fixed on screen while the user keeps scrolling through its startend range, so the content behind or around it can animate, change, or scroll past while the pinned element itself doesn't move. Mechanically, ScrollTrigger does this by swapping the pinned element's positioning to fixed (or absolute, depending on context) for the duration of the pin, which removes it from the normal flow of the page.

That removal is exactly the problem a spacer exists to fix. The moment an element goes from participating in normal document flow to being pinned in place, every element below it in the page would otherwise snap upward to fill the gap it left — the page's total scrollable height would shrink by however tall the pinned element was, and the scroll position that used to correspond to "the pinned section's range" now points at the wrong content entirely. ScrollTrigger avoids that by inserting an empty spacer element of the same height into the document flow in the pinned element's place, so the page's total height, and therefore its scrollable range, stays exactly as long as it would have been if the element had just scrolled normally. The pinned element floats fixed on top of that reserved blank space for as long as the pin's scroll range lasts, then unpins and rejoins the flow once end is reached.

ScrollTrigger.create({
  trigger: ".hero",
  start: "top top",
  end: "+=800",       // stay pinned for 800px of additional scroll
  pin: true,           // .hero goes fixed; a same-height spacer fills its old spot
});

Snapping

snap rounds the trigger's progress to the nearest value in a fixed set (or a step, like 1 / numberOfSlides) once the user stops actively scrolling — it animates the scroll position itself (or the scrubbed timeline's progress, depending on configuration) the rest of the way to that nearest snap point. It's built on the same progress number as everything above; snapping just adds "and if the user has stopped, nudge progress the last bit toward the closest round value" as a small tween of its own, rather than leaving progress sitting wherever the raw scroll offset last left it.

Performance: batching reads to avoid layout thrashing

Computing start/end boundaries and current progress requires reading geometry — getBoundingClientRect() and scroll offsets — on every relevant scroll or resize event, potentially across many ScrollTrigger instances on the same page. Read that geometry naively, one trigger at a time, interleaved with any writes those triggers also cause, and you land squarely in the trap the layout thrashing lesson describes: a read immediately following a write forces a synchronous layout, and doing that once per trigger, per scroll event, adds up fast on a page with a dozen pinned sections. ScrollTrigger avoids this by batching its geometry reads — recalculating all of its instances' boundaries together in one pass, on a shared refresh cycle, rather than letting each instance measure the DOM independently and out of order with everything else's writes. The mechanism is the same read-then-write discipline that lesson teaches by hand; ScrollTrigger just applies it internally so you don't have to.

The unifying idea

Every mechanism in this lesson — scrub, toggleActions, pinning, snapping — is downstream of one small computation: turn a scroll offset into a 0-to-1 progress across a range, using exactly the same normalized-progress representation the very first lesson in this module built for time. Progress is progress, whatever clock produces it. Time produces it by accumulating dt every tick; ScrollTrigger produces the identical shape of number by measuring where an element sits in the viewport right now. Everything downstream of that number — seeking a timeline, firing a callback at a boundary, catching up smoothly instead of snapping — doesn't know or care which clock generated its input.

Where this goes next

You've now seen GSAP's core idea — a driven, normalized 0-to-1 progress — expressed on top of three different clocks across this module: real elapsed time (the ticker), an eased reshaping of that time (easing), and now scroll position (ScrollTrigger). The final lesson, GSAP vs CSS vs the Web Animations API, steps back from mechanism entirely and asks the practical question this module has been building toward: given everything GSAP can do that CSS and WAAPI can't — scroll-scrubbing very much included — when is that power worth its cost, and when is a plain CSS transition simply the better tool?

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Write the progress formula ScrollTrigger computes from scroll offset, start, and end. What does it produce at the exact start boundary, and at the exact end boundary?
  2. In what precise sense is scrub: true doing the same operation as a scrubber slider's input handler from the first lesson in this module?
  3. What does a numeric scrub value (like scrub: 1) change about how progress is applied, and what earlier concept from this module is that mechanism reusing?
  4. Explain mechanically why pinning an element without a spacer would corrupt the scroll math for every ScrollTrigger below it on the page.
  5. What are onEnter/onLeave/toggleActions watching for, in terms of the same progress number scrub mode uses continuously?
  6. Why does computing many ScrollTriggers' boundaries naively, one at a time, risk forced synchronous layout, and how does ScrollTrigger's batching avoid it?
  7. State the lesson's unifying claim in one sentence: what does ScrollTrigger share with the ticker, and what's the one thing that differs between them?