GSAP timeline under scrub
Author the choreography in GSAP, let ScrollVars drive it. One listener, one writer.
sv
drives
gsap
<div data-x> <!-- no data-sv: tracked in JS with pin: '250vh' (the helper sets the height) -->
<div class="sv-stage grid place-items-center">…</div>
</div>
<!-- zero-wrapper pages: use the css pane's guarded track()/onPin callback,
never gsap.ticker: it has no reduced-motion guard and reads an empty
var as 0 the instant the tracker releases -->
<div class="scene"> <!-- no data-sv: tracked in JS below -->
<div class="sv-stage">…stage…</div> <!-- the sticky viewport, from pin.css -->
</div>
<script>
// author in time-space, consume as a scrub. This stays input-driven:
const tl = gsap.timeline({ paused: true })
.from('.sv-stage > *', { y: 140, opacity: 0, stagger: 0.2 })
// reduced motion settles the timeline instead of scrubbing it; a failed
// or released tracker never fires onPin again, so it settles too, or the
// .from() content stays at opacity 0 forever
let progress = 0, tracking = true
const settle = () => tl.progress(prefersReducedMotion() || !tracking ? 1 : progress)
settle()
onMotionChange(settle)
track(document.querySelector('.scene'), {
pin: '250vh', // the helper owns the wrapper height
onPin: (p) => { progress = p; settle() }, // ScrollVars steers, GSAP renders
onStatus: (status) => { tracking = status !== 'failed' && status !== 'released'; settle() },
})
// Do NOT also create a ScrollTrigger. One scroll listener, one writer.
</script>
const stage = useRef<HTMLDivElement>(null)
const tl = useRef<gsap.core.Timeline | null>(null)
const progress = useRef(0)
const tracking = useRef(true)
// reads only refs, so identity does not matter to the effect below
const settle = () => tl.current?.progress(prefersReducedMotion() || !tracking.current ? 1 : progress.current)
useEffect(() => {
// scoped to this instance's own stage, never a bare class selector: two
// of these on one page must not animate each other's children
tl.current = gsap.timeline({ paused: true })
.from(stage.current!.children, { y: 140, opacity: 0, stagger: 0.2 })
settle()
const off = onMotionChange(settle)
return () => { off(); tl.current?.kill() }
}, [])
<Track
pin="250vh"
onPin={(p) => { progress.current = p; settle() }}
onStatus={(status) => { tracking.current = status !== 'failed' && status !== 'released'; settle() }}
>
<div ref={stage} className="sv-stage">…</div>
</Track>