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.

importgsap + ScrollTrigger + DrawSVGPluginsvg-scroll-draw
version3.15.02.23.3
licenceGreenSock Standard (free of charge)MIT
gzip47.5 KB11.6 KB
in ReactuseGSAP · @gsap/react<ScrollDraw> · <ScrollAnimate>
Fig. 1 — what changes, measured
01The problem

What goes wrong with the usual setup

  1. 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.
  2. 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.
  3. 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.
  4. 4ScrollTrigger drives a scrubbed tween from JavaScript, frame by frame, for as long as you scroll through it.
027 steps

The switch, step by step

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

    terminal3 lines
    npm install svg-scroll-draw
    # once nothing imports gsap any more:
    npm uninstall gsap @gsap/react
  2. Step 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.js23 lines
    // 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%' },
    });
  3. 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.js24 lines
    // 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',
    });
  4. 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.js22 lines
    // 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
    });
  5. 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.js25 lines
    // 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 },
      ],
    });
  6. 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.js19 lines
    import { 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()
  7. 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.jsx24 lines
    '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>
        </>
      );
    }
03unchanged

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().
04questions

Questions

Q1

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.

Q2

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.

Q3

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.

Q4

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.

Q5

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.

Q6

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.

Q7

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.

05More guides

More migration guides