← All articles

[ Blog ]

September 1, 2026

7 min read

Category: Tutorials

GSAP Scrub Timeline Breaks on Fast Scroll: The Fix

A scroll-scrubbed GSAP timeline plays perfectly until a fast scroll jump leaves an element stuck visible forever. The real cause, the fix, and how to QA it.

GSAPScrollTriggerAnimationDebuggingNext.js
GSAP Scrub Timeline Breaks on Fast Scroll: The Fix

I had a pinned, scroll-scrubbed GSAP timeline that swapped element visibility beat by beat — a narrative sequence where each scroll segment reveals the next panel and hides the last. On a normal, incremental scroll it was flawless. Then someone pressed End, then Home, and a panel from three beats later stayed stuck on screen — autoAlpha: 1, painted over everything else, for the rest of the session. No console error. No warning. Just a permanently broken page until a hard reload.

If your GSAP scrub timeline breaks on fast scroll — a scrollbar drag, an End/Home jump, or a big anchor-link seek — while smooth forward scrolling looks perfect, this is almost certainly the same bug: a visibility swap living inside a timeline callback instead of a real tween.

The symptom: fine on scroll, broken on a jump

The tell is specific enough to be diagnostic. A scrubbed ScrollTrigger timeline:

  • Plays correctly on any smooth, incremental scroll — forward or backward.
  • Corrupts only after a large, instantaneous jump in scroll position: End then Home, dragging the scrollbar thumb fast, or a router/anchor scroll that lands far from where the playhead was.
  • Leaves one specific element stuck in the "on" state it was last set to, masking the beats after it, until a full page reload resets JS state.

Because normal QA scrolls incrementally, this bug hides in review and only shows up when a real user scrubs fast — which they will, especially on a long pinned section they're skimming.

Why it happens: a callback is not a tween

The timeline in question toggled visibility like this:

tl.add(() => {
  gsap.set(panelA, { autoAlpha: 0 });
  gsap.set(panelB, { autoAlpha: 1 });
}, position);

This looks reasonable — it's inside the timeline, at a specific position, using gsap.set(). But a plain function handed to tl.add() is not a renderable tween. GSAP's timeline only knows how to recompute state from tweens (.to, .fromTo, .set) when the playhead is scrubbed to an arbitrary position — it interpolates every tween's progress from the timeline's absolute time. A callback has no progress value to interpolate. GSAP can only re-fire it the moment the playhead crosses that exact position, in whichever direction it's moving.

On smooth scroll, the playhead crosses every position in order, so the callback fires exactly when you'd expect and the one-way gsap.set() calls look correct. On a fast jump — say from second 40 of the timeline straight to second 2 — the playhead skips over the callback's position entirely. It never re-fires. Whatever gsap.set() last wrote stays applied, forever, because nothing tells GSAP to reconcile it against the new playhead time. Meanwhile every real tween on that same timeline does recompute correctly for the jump, which is why only the callback-driven element ends up wrong — everything else self-corrects and it looks like one random panel is haunted.

The fix: tl.set(), not tl.add(() => gsap.set())

The fix is a real zero-duration tween on the timeline instead of a callback wrapping the same API:

tl.set(panelA, { autoAlpha: 0 }, position);
tl.set(panelB, { autoAlpha: 1 }, position);

That's the entire diff. tl.set() is a tween — a zero-duration one — so it participates in the timeline's normal progress calculation just like a .to(). On any seek, in any direction, by any distance, GSAP recomputes which tl.set() calls should be "active" at the new playhead time and applies them. The visual result is identical to the callback version — an instant, zero-duration hard cut — but it's now reversible and seek-safe instead of a one-way trigger.

// before — one-way trigger, breaks on a scrub jump
tl.add(() => {
  gsap.set(panelA, { autoAlpha: 0 });
  gsap.set(panelB, { autoAlpha: 1 });
}, position);

// after — a real zero-duration tween, recomputed on every seek
tl.set(panelA, { autoAlpha: 0 }, position);
tl.set(panelB, { autoAlpha: 1 }, position);

tl.add(callback) vs tl.set() — why one survives a jump

tl.add(() => gsap.set(...))tl.set(target, vars, position)
Recomputed on seek/jumpNo — fires only when crossedYes — always matches playhead time
Reversible on scroll-backNo — one-way triggerYes — behaves like any other tween
Visual result on smooth scrollLooks correctLooks correct (identical)
Visual result on a fast jumpCan get stuck permanentlyCorrect — self-heals
When it's the right toolA genuine side effect (analytics ping, route change)Any visual/state change tied to scroll position

If you truly need a one-shot side effect — firing an analytics event, say — a callback is fine, because side effects that fire once per crossing are the point. The bug only exists when a callback is used to set visual state that must be correct at an arbitrary playhead. That kind of state belongs on a tween.

QA it with the jump test, not smooth scroll

Smooth, incremental scrolling will never catch this — the bug is specifically about non-incremental seeks. Add the jump test to your review checklist for any scrubbed timeline:

  1. Scroll to roughly the middle of the pinned/scrubbed section.
  2. Press End, then immediately press Home (or Home then End).
  3. Drag the scrollbar thumb from top to bottom fast, then back up fast.
  4. On a touch device, do a hard, fast flick-scroll past the section, then scroll back.
  5. After each jump, check every element the timeline controls — not just the one currently in view. Anything left in the wrong autoAlpha, opacity, visibility, or display state is a callback that needs to become a tween.

If step 5 turns anything up, grep the timeline for .add(() => and .call( and move whatever sets visual/animated state into .set() / .to() / .fromTo() at the same position. If a side-effect callback genuinely can't become a tween, make it idempotent and reconcile it from ScrollTrigger's own onUpdate/onRefresh progress value instead of relying on crossing direction — that way it self-corrects on the next update regardless of how it got there.

The rule for every scrubbed timeline

Drive all scroll-scrubbed state through real tweens — never through a callback wrapping the same gsap.set()/gsap.to() calls. A tween is a value GSAP can recompute from progress; a callback is a one-way trigger that only fires when crossed. The moment scrubbing gets fast enough to skip a crossing — and on a long page, a user will scroll that fast — anything living in a callback is a bug waiting to surface in production, not in your QA pass.

This is the same family of gotcha covered in this GSAP ScrollTrigger tutorial on pin, scrub, and parallax — if you're building the scrub timeline itself, start there for the start/end syntax and the pin-jump fix. If the stutter you're chasing is in a scroll-scrubbed video rather than a timeline, the fix is almost always the keyframes, not the JS. And if the whole page feels heavy under a scrubbed timeline, check that Lenis and GSAP ScrollTrigger are wired to one shared scroll driver — two competing scroll loops make jump bugs like this one harder to isolate, not easier.

FAQ

Why does my GSAP animation only break when scrolling fast?

Because the bug lives in how the playhead advances, not in the animation logic itself. Smooth scroll advances the playhead through every intermediate position, so anything tied to "crossing a point" (a callback) fires correctly. A fast jump skips over those positions entirely, so callbacks never fire while real tweens still recompute correctly from the new absolute time — leaving only the callback-driven state stale.

Does this affect gsap.set() calls outside a timeline too?

No — a standalone gsap.set() runs once, immediately, which is exactly what you want for a one-off state change. The bug is specific to gsap.set() (or any code) wrapped in tl.add(callback)/tl.call() inside a scrubbed timeline, where GSAP needs to recompute state from an arbitrary progress value and a callback gives it nothing to recompute.

Is tl.set() slower than a callback?

No — tl.set() is a zero-duration tween; the render cost is the same as calling gsap.set() directly. You're not adding animation time, you're changing how GSAP tracks the same instantaneous state change so it survives a seek.


If you're shipping a scroll-scrubbed narrative sequence and want it audited for this class of bug before it ships to real users — or want the whole animation system built right the first time — get in touch.

Related posts