An Exploration of GSAP's ScrollTrigger in React

Madeline Streilein background

Madeline Streilein —

Scroll Trigger Image

We recently completed a refresh of the Durham Civil Rights Heritage Project with the Durham County Library

A key design feature of the refresh is an interactive timeline that juxtaposes civil rights events in Durham, North Carolina (where Savas Labs is based) to those that happened elsewhere in the nation. Certain Durham events link out to individual event pages where users can learn more in depth about that place in time. As users scroll, the timeline fills, and a chain of "animation" follows.

I have implemented a number of scroll features, such as mobile jump menus that update with the active section as one scrolls over the sections, back-to-top buttons that don't appear until the user has scrolled past an certain element, and more. There are a variety of ways to handle this kind of scroll-based functionality. In internal projects, we've switched from comparing scrollTop() to an element's position to utilizing the Intersection Observer API to improve performance. GSAP's ScrollTrigger Plugin is an improvement on IO in that it's not always watching -- an expensive action. From the docs: 

ScrollTrigger does not constantly watch every element and check its positioning in the viewport on each tick. We're obsessed with performance, and that'd be far too costly. Instead, ScrollTrigger does the processing up-front to figure out where the start/end points are in the natural document flow. In other words, "this ScrollTrigger will be active when the scrollbar is between ___ and ____". Then, it debounces the "scroll" events and only updates things on the next requestAnimationFrame, perfectly synced with GSAP and screen refreshes. It only watches the scroll position. Period. That means it's fast.

Off to the Races

I was extremely excited when I got to dig in. The promotional videos for GSAP's ScrollTrigger are absolute eye candy:

This post outlines my experience implementing a combination of React hooks, GSAP's newly released ScrollTrigger plugin, and pure CSS to bring the civil rights timeline to life in an SPA.

- GSAP with React Hooks

- ScrollTrigger for Event Cards

- ScrollTrigger for Timeline Line

  • Seeking assistance: codesandbox, immediate response
  • Pretty close, but not fully there
  • Implementing a Pure CSS Solution

-Takeaways

Eventually we abandoned the scrollTrigger approach to "fill" the timeline line itself in favor of a pure CSS solution.

GSAP with React Hooks

The first thing to tackle was how to implement this plugin in React and then the best way to implement it with React Hooks.

Add gsap to your project like any other React app, with yarn or npm. Don't forget to register your plugin. After that, the name of the game is useRefs so that we can access the elements imperatively. Here, I add each Year list item and each card to the yearRefs current array.

// Timeline.js
import React, { useEffect, useRef } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);
gsap.core.globals('ScrollTrigger', ScrollTrigger);

const timeline = ({ timeline }) => {
  const yearRefs = useRef([]);
  yearRefs.current = [];

  const addToYearRefs = el => {
    if (el && !yearRefs.current.includes(el)) {
      yearRefs.current.push(el);
    }
  };


return (
   ....
   <YearListItem
        value={eventsPerYear.year}
        key={i}
        ref={addToYearRefs}
    >
        ...
        <Card ref={addToYearRefs} />
        ...
    </YearListItem>
)
};

// Card.js

import React from 'react';

const card = React.forwardRef((ref) => {
  return (
    <Card ref={ref} >
    </Card>
  );
});

ScrollTrigger for Year, Event Cards

I was able to set up the scrollTrigger for each year on the timeline quite easily after consuming the official documentation and a handful of GSAP forum posts.

// Timeline.js

useEffect(() => {
    // Year and card animation.
    yearRefs.current.forEach(el => {
      gsap.from(el, {
        scrollTrigger: {
          trigger: el,
          scrub: true,
          start: 'top center',
          end: 'top top',
          toggleClass: 'active',
        },
      });
    });
  }, [timeline]);

The scrub variable links each ref directly to the scrollbar so that it acts like a scrubber.

The first and second values of the start and end variables define the position of the trigger and the position of the viewport.

Here, I'm saying I want the active class to be added when the top of each ref hits the center of the viewport and to be removed when the top of the trigger hits the top of the viewport. This way, I can ensure that the refs are triggered in the middle of the page and reverted when the user has scrolled another half-page length down when they should be above the top of the viewport and out of sight.

The "animations" on the year refs and event cards are set off when each has the active class given by the ScrollTrigger.

Note: the start and end values can be numbers denoting page position; therefore, it is possible to use ScrollTrigger without a defined trigger value.

ScrollTrigger for Timeline Line

I experienced some serious wonkiness with my first couple of attempts at the timeline line animation, admittedly almost entirely due to a lack of experience with this tool. I had trouble delineating which variables were necessary and which were superfluous.

// Timeline.js, in the same useEffect as the year Refs.
gsap.from('#line', {
      scrollTrigger: {
        trigger: 'body',
        scrub: true,
        start: 'top bottom',
        endTrigger: '#footer',
        end: 'top top',
        snap: {
          snapTo: yearRefs,
          duration: { min: 0.2, max: 3 },
          delay: 0.2,
          ease: 'power1.inOut',
        },
      },
      scaleY: 0,
      transformOrigin: 'top, top',
      ease: 'none',
});

...
  
<Line id='line />

...
const Line = styled.span`
  width: 6px;
  max-height: 181001px;
  height: 100%;
  position: absolute;
  top: 0;
  bottom: 0;
  left: 43px;
  z-index: 1;
  background-color: ${props => props.theme.colors.darkGreen};

@media ${props => props.theme.breakpoints.lg} {
    left: calc(50% - 1px);
  }
`;

If I remember correctly, this code resulted in a line that sprang from the middle of the timeline (quite a scroll down the page) that I could barely see pop into view over the bottom of the view port....headed in the wrong direction.

Seeking Assistance

I turned to the GSAP forum for help troubleshooting the behavior of my timeline line. At this point there was a ton of work on the project that was irrelevant to the line "animation" so I spent time removing code from the snippet I posted to the forum. I was delighted to find that someone had responded quite quickly, even if to ask that I repeat the process again in Codesandbox. The response that followed resulted in functionality that was the closest yet to what I desired. The timeline line and year refs were almost in sync.

// Timeline.js, in the same useEffect as the year Refs.
gsap.from('#line', {
      scrollTrigger: {
        scrub: true,
        trigger: "#line-anchor",
        start: "top center",
        end: "bottom bottom",
        endTrigger: "#footer",
      },
      scaleY: 0,
      transformOrigin: "top, top",
      ease: "none",
    });

// TimelineKey.js
<Sticky id="line-anchor">


Pretty Close, but Not Fully There

-I translated this update to the repo, where I knew the behavior would be different due to the existence of multiple elements above the timeline ordered list (the header, nav, an h1), and was successful...or so I thought. I set the trigger to the id of the element that immediately preceded the timeline, set the endTrigger as the id of the footer, and committed. I was thrilled that it worked. However, in the coming days, I rarely saw the line animation again. Sometimes it would flash on the screen on initial page load before disappearing for the rest of that work session. Occasionally I could see it when Chrome DevTools was open, but not when it was closed. Sometimes I wouldn't see the line for days, despite not changing any code relevant to the scrollTrigger animation. I did not want to give up (we were so close!), but I was also running out of troubleshooting ideas. Was copying over everything in my local environment to Codesandbox for feedback from the folks in the forum going to be worth my time?

A Pure CSS Solution

Luckily we did not get to that point. A colleague had an idea that enabled me to continue to use scrollTrigger for the year refs, but implement a pure CSS solution for the line animation itself. The solution? Simply: an absolutely positioned element from the top to the bottom of the viewport that is always 50% of the height of the viewport. All that was left to do was increase the z-index of the header so the line would only show once the user scrolled down to where the timeline began.

// Timeline.js 
...
  
<Line />

...
/* Timeline line denoting scroll position */
const Line = styled.span`
  width: 6px;
  height: 50vh;
  position: fixed;
  top: 0;
  left: 43px;
  z-index: 1;
  background-color: ${props => props.theme.colors.darkGreen};
  @media ${props => props.theme.breakpoints.lg} {
    left: calc(50% - 1px);
  }
`;


This commit here includes the changes: https://github.com/savaslabs/dlib/commit/9441f3c67ae987aad26694ba693d2f573a30e8a6.

Voila! The line and year refs are perfectly synced.

GSAP's ScrollTrigger met my needs for updating styling based on the scroll. However, there's still a lot I cannot explain about why the implementation of the timeline line was not as expected.

Takeaways

Benefits

  • GSAP seems incredibly fast and powerful. There's a ton more of the plugins that I hope to learn soon, especially their Tween and DrawSVG.
  • Individuals on the GSAP Forum are quick to respond, knowledgeable, and willing to help.
  • The docs are pretty flushed out.
  • ScrollTrigger is really smooth and performant for the year, and card refs.

Drawbacks

  • There was definitely room for elaboration on some fundamental values of ScrollTrigger in the docs.
  • Even with the tweaks from the forum post, you can see in the codesandbox that despite the start and end values for the year refs set to the middle of the screen, as you scroll down, the refs become active closer and closer to the top of the viewport, until it becomes active outside of the viewport.
  • Why didn't the line animation consistently appear in my repo? At least if it never appeared, I could understand that the problem was on my end, but it sometimes appeared. That was and remains perplexing.
  • Given that we were utilizing styled components throughout this project, I would've preferred if I could update the react state when each ref was triggered on scroll instead of toggling a class. That way, I could've removed our index.css and kept all the styling within each component.

The code snippets in this post were significantly pared down. I invite you to check out the live site!