ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

GSAP timeline under scrub

Author the choreography in GSAP, let ScrollVars drive it. One listener, one writer.

Use it for: The page that genuinely needs timeline authoring. You keep the driver; GSAP renders.
Knobs: the timeline is yours; ScrollVars owns scroll (pin/onPin). Never let GSAP add its own scroll listener

sv
drives
gsap

Honesty: this page pays GSAP's ~46 KB. Adopt it per page that needs timeline authoring, never globally, or the bundle argument dies for that page.

Install: npx scrollvars add gsap-scrub
Styles: import 'scrollvars/styles/pin.css'

<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>

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.