guide / gsap scrolltrigger in react7 steps
From ScrollTrigger, call by call.
GSAP is free — every plugin, ScrollTrigger and DrawSVG included — and it does far more than this library. If what you use it for is scroll-scrubbed line drawing, pinned sections and enter/leave callbacks, svg-scroll-draw does that with the same trigger syntax and callback names, in a 11.6 KB main entry instead of 47.5 KB. It is not a drop-in: each call is rewritten by hand, and the steps below map them one at a time, including the parts that have no equivalent.
What goes wrong with the usual setup
- 1Three modules and a registration call before the first tween: gsap, gsap/ScrollTrigger and gsap/DrawSVGPlugin, then gsap.registerPlugin(). Together they are 47.5 KB gzipped — core 27.7 KB, ScrollTrigger 17.6 KB, DrawSVG 2.2 KB — measured against gsap 3.15.0.
- 2A scrubbed line needs three settings to agree: drawSVG on the tween, scrub: true on the ScrollTrigger, and ease: 'none'. Leave the ease out and GSAP's default power1.out curve bends the drawing against the scroll.
- 3In React, every tween and ScrollTrigger made in an effect has to be reverted on unmount. useGSAP() from the separate @gsap/react package, or gsap.context(), does it; a plain useEffect does not, and StrictMode’s development double mount then creates each ScrollTrigger twice.
- 4ScrollTrigger drives a scrubbed tween from JavaScript, frame by frame, for as long as you scroll through it.
The switch, step by step
Step 01 · terminal
Install it beside GSAP
There is no codemod for GSAP — npx svg-scroll-draw migrate only rewrites AOS, ScrollReveal and vivus — so move one call at a time. Both libraries can run on the same page while you do, as long as no element is animated by both.
terminalnpm install svg-scroll-draw # once nothing imports gsap any more: npm uninstall gsap @gsap/reactStep 02 · route.js
A scrubbed DrawSVG line becomes scrollDraw
The element you pass is the trigger, and the strokes inside it are what draws. scrub: true needs no option, because every svg-scroll-draw call follows the scroll, and the default easing is linear, as ease: 'none' is. start and end take an element edge (top, center or bottom) and a viewport position (top, center, bottom or a percentage); pixel values and relative ends such as '+=500' are not supported. Leave trigger out and supporting browsers run the draw on native CSS, with no JavaScript per frame.
route.js// before: GSAP + ScrollTrigger + DrawSVG import gsap from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin'; gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin); gsap.from('#route path', { drawSVG: 0, ease: 'none', scrollTrigger: { trigger: '#route', start: 'top 80%', end: 'bottom 40%', scrub: true, }, }); // after: svg-scroll-draw import { scrollDraw } from 'svg-scroll-draw'; scrollDraw('#route', { trigger: { start: 'top 80%', end: 'bottom 40%' }, });Step 03 · panel.js
A scrubbed tween becomes scrollAnimate
props takes [from, to] pairs of CSS values; a single value animates from the current computed style, as gsap.to() does. Transforms are CSS strings rather than x and y. scrollAnimate's default easing is ease-out, so pass 'linear' to match ease: 'none'. Each call animates one element — the first match of a selector — so loop over a list, or pass an array to scrollAnimateGroup from svg-scroll-draw/group.
panel.js// before gsap.fromTo('#panel', { opacity: 0, y: 40 }, { opacity: 1, y: 0, ease: 'none', scrollTrigger: { trigger: '#panel', start: 'top 85%', end: 'top 50%', scrub: true, }, }); // after import { scrollAnimate } from 'svg-scroll-draw'; scrollAnimate('#panel', { props: { opacity: [0, 1], transform: ['translateY(40px)', 'translateY(0px)'], }, trigger: { start: 'top 85%', end: 'top 50%' }, easing: 'linear', });Step 04 · story.js
pin: true becomes scrollPin
scrollPin holds the element at the top of the viewport, as start: 'top top' does, for pinDistance pixels of scroll — one viewport height if you leave it out; top: 80 pins it 80px down instead. It wraps the element in a spacer that reserves that distance, as pinSpacing: true does; there is no pinSpacing: false. A pinned scrub is two calls: onProgress reports 0–1 through the pin, and seek() moves any instance to that point and holds it there. svg-scroll-draw/pin is its own 1.6 KB entry.
story.js// before gsap.from('#stage path', { drawSVG: 0, ease: 'none', scrollTrigger: { trigger: '#stage', start: 'top top', end: '+=1500', pin: true, scrub: true, }, }); // after import { scrollDraw } from 'svg-scroll-draw'; import { scrollPin } from 'svg-scroll-draw/pin'; const draw = scrollDraw('#stage svg'); scrollPin('#stage', { pinDistance: 1500, // end: '+=1500' onProgress: (p) => draw.seek(p), // scrub the drawing while pinned });Step 05 · diagram.js
A scrubbed timeline becomes scrollDrawTimeline
A timeline places tweens in seconds; a track is a slice of the scroll window from 0 to 1. Divide each tween's start and end by the timeline's length — 10 here — to get from and to. Each track draws the strokes its selector matches inside the container, linear unless you give it an easing. Tracks only draw: a timeline that also fades or moves elements needs scrollAnimate calls with their own windows, and there are no labels to snap to.
diagram.js// before const tl = gsap.timeline({ defaults: { ease: 'none' }, scrollTrigger: { trigger: '#diagram', start: 'top 80%', end: 'bottom 20%', scrub: true, }, }); tl.from('#diagram .outline', { drawSVG: 0, duration: 5 }, 0) .from('#diagram .detail', { drawSVG: 0, duration: 5 }, 3) .from('#diagram .label', { drawSVG: 0, duration: 3 }, 7); // after import { scrollDrawTimeline } from 'svg-scroll-draw/timeline'; scrollDrawTimeline('#diagram', { trigger: { start: 'top 80%', end: 'bottom 20%' }, tracks: [ { selector: '.outline', from: 0, to: 0.5 }, { selector: '.detail', from: 0.3, to: 0.8 }, { selector: '.label', from: 0.7, to: 1 }, ], });Step 06 · nav.js
Callbacks, refresh and markers
The four edge callbacks keep their names and fire at the same moments. onUpdate becomes onProgress, which receives the 0–1 progress rather than a ScrollTrigger. There is no global ScrollTrigger.refresh(): every instance re-measures on resize by itself, and scrollDraw, scrollPin and scrollDrawTimeline also have refresh() for layout changes a resize does not report — scrollAnimate has none of its own. markers: true becomes debug: true on scrollDraw, or devtools.enable() for every instance at once; both do nothing in production builds.
nav.jsimport { scrollDraw } from 'svg-scroll-draw'; import { devtools } from 'svg-scroll-draw/devtools'; const nav = document.querySelector('nav'); const meter = document.querySelector('meter'); const route = scrollDraw('#route', { trigger: { start: 'top center', end: 'bottom center' }, onEnter: () => nav.classList.add('on'), onLeave: () => nav.classList.remove('on'), onEnterBack: () => nav.classList.add('on'), onLeaveBack: () => nav.classList.remove('on'), onProgress: (p) => (meter.value = p), // was onUpdate: (self) => self.progress debug: true, // was markers: true }); devtools.enable(); // start and end lines for every instance route.refresh(); // was ScrollTrigger.refresh(), for this instance route.destroy(); // was kill() or ctx.revert()Step 07 · Story.jsx
useGSAP becomes a component
useGSAP() and registerPlugin() go. ScrollDraw, ScrollAnimate, ScrollText, ScrollCounter and ScrollVideo create their animation when they mount and destroy it when they unmount, so StrictMode’s double mount is harmless; their options are read once, at mount. When a component needs the number itself, useScrollDrawProgress(ref, options) returns the 0–1 progress as state. scrollPin and scrollDrawTimeline have no component: create them in a useEffect and return destroy, as here. In the Next.js App Router, all of this goes in a client component.
Story.jsx'use client'; import { useEffect, useRef } from 'react'; import { ScrollDraw } from 'svg-scroll-draw/react'; import { scrollPin } from 'svg-scroll-draw/pin'; export function Story() { const stage = useRef(null); useEffect(() => { const pin = scrollPin(stage.current, { pinDistance: 1200 }); return () => pin.destroy(); }, []); return ( <> <ScrollDraw trigger={{ start: 'top 80%', end: 'bottom 40%' }}> <svg viewBox="0 0 400 120" fill="none" stroke="currentColor"> <path d="M10 100 C 120 10, 280 10, 390 100" /> </svg> </ScrollDraw> <section ref={stage}>Pinned for 1200px of scroll.</section> </> ); }
What stays exactly the same
- The trigger window syntax: an element edge, then a viewport position — "top 80%", "center center", "bottom top" — and the same default window, from top bottom to bottom top.
- The four edge callbacks, by name and by moment: onEnter, onLeave, onEnterBack and onLeaveBack, on scrollDraw, scrollAnimate and scrollPin.
- Your SVG: scrollDraw draws strokes the way DrawSVG does, with stroke-dasharray and stroke-dashoffset, on paths, lines, polylines, polygons, rects and circles by default — the selector option picks anything else.
- Control from code: the instance you get back has pause() and resume(), seek(0.5) where a tween has progress(0.5), and destroy() where it has kill().
Questions
Isn’t GSAP free now?
Yes. Since 2025 GSAP and every plugin that used to be members-only — DrawSVG, SplitText and MorphSVG among them — cost nothing, for commercial work too. They ship under GSAP’s Standard "No Charge" licence, which is not open source and bars use in no-code visual animation tools that compete with Webflow; svg-scroll-draw is MIT. Price is no reason to switch. Weight and a native CSS path can be.
What in ScrollTrigger has no equivalent?
Tweens on a clock: gsap.to() on any property or object, independent of scroll — only SVG strokes can play over time here, with scrollDraw’s autoplay and duration. Snapping to progress points or labels; scrollSnap from svg-scroll-draw/snap snaps between whole sections instead. Scrub smoothing such as scrub: 1 — progress follows the scroll position exactly. ScrollTrigger.batch(); scrollReveal from svg-scroll-draw/reveal reveals every match with a stagger per row, but has no batched callbacks. pinSpacing: false, pixel or relative trigger positions, and a global refresh(). The rest of GSAP — Flip, MorphSVG, MotionPath, Draggable — is out of scope.
Does DrawSVG’s segment syntax carry over?
Only the whole stroke. scrollDraw draws each stroke from its start to its end, as drawSVG does going from 0 to "0% 100%", and direction: "reverse" erases it instead. Segments — "40% 60%", a dash travelling along the path, or drawing out from the middle — have no equivalent.
How do toggleActions and once map?
Every call scrubs by default and runs backwards when you scroll back. once: true holds the furthest point reached, so a drawing never undraws — what toggleActions: "play none none none" or once: true is usually there for. A timed play on entry exists for SVG strokes only: autoplay: true with a duration in milliseconds, replayed on each entry unless once is set. Per-edge actions such as "play pause resume reverse" have no equivalent; call pause(), resume() or replay() from the four callbacks instead.
Will the migrated animations run on native CSS?
The simplest ones will. A scrollDraw or scrollAnimate with the default window, no callbacks, no once and an easing CSS can express is handed to the browser’s scroll-driven CSS animations where they are supported, and then runs with no JavaScript per frame. A custom start or end, a callback or once keeps it on the JavaScript engine, which runs only while the element is on screen. On either path the browser still repaints stroke-dashoffset on the main thread.
Does it handle horizontal scrolling and scroll containers?
horizontal: true becomes axis: "x", and scroller becomes scrollContainer (a selector or an element), on scrollDraw and scrollAnimate. The pinned sideways strip — panels moved with xPercent while the page scrolls down — is scrollHorizontal from svg-scroll-draw/horizontal: you write the position: sticky wrapper in CSS, and it moves the track.
What about reduced motion?
ScrollTrigger leaves it to you, usually through gsap.matchMedia(). With prefers-reduced-motion: reduce, scrollDraw and scrollAnimate show their finished state straight away; scrollDrawTimeline keeps scrubbing, since that moves only with the reader’s own scroll, but skips its time-based loop.