← All articles
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.

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:
EndthenHome, 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/jump | No — fires only when crossed | Yes — always matches playhead time |
| Reversible on scroll-back | No — one-way trigger | Yes — behaves like any other tween |
| Visual result on smooth scroll | Looks correct | Looks correct (identical) |
| Visual result on a fast jump | Can get stuck permanently | Correct — self-heals |
| When it's the right tool | A 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:
- Scroll to roughly the middle of the pinned/scrubbed section.
- Press
End, then immediately pressHome(orHomethenEnd). - Drag the scrollbar thumb from top to bottom fast, then back up fast.
- On a touch device, do a hard, fast flick-scroll past the section, then scroll back.
- After each jump, check every element the timeline controls — not just the one currently in
view. Anything left in the wrong
autoAlpha,opacity,visibility, ordisplaystate 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.


