Cover image for this blog post

Animating React components w/ GSAP v3 💫

By Prasath Soosaithasan • 9 minutes
Feb 24, 2020

Animations in React is a topic that lacks official guidance. The most referenced package when you start digging into animations is react-transition-group (RTG). When you study their website they admit that they are not an animation library per se and that all they do is to "expose transition stages", i.e. allowing the addition and removal of styles or classes from a component when it reaches various phases in its lifecycle. While it gets you started with animating elements onto and off the screen, more interesting ideas cannot be accomplished with this package alone. Once you realize that your desired animation concept does not fit into the mechanics of RTG, you start your quest for a more full-fledged solution. Best practices have not yet emerged. That is about to change with the advent of React hooks and GSAP v3.

Add animation when element is scrolled into view

Demo for adding animation when element is scrolled into view
Fig1: Animation triggered by IntersectionObserver when element got scrolled into view

One of the most seen use cases of animations on the web is to breathe life into elements when they are scrolled into the document viewport. This is nothing new from a user experience perspective. This effect has been around for a long time. You would listen to the scroll event of the global window variable and check at every occurence when the scroll event is triggered, i.e. every time a scroll is recorded, whether or not a particular animation should be triggered. Imagine you have a very long single page application and you are listening on the window scroll event to fade in the header of the page. Now, when you scroll down and read content in the middle and towards the end of the page, the check for the header animation is still being executed over and over. The problem of this mechanism is that it is computationally expensive slowing down your page considerably.  That's why a lot of developers shied away from using this technique.

The need to do this type of operation has been acknowledged though by the W3C and it was only in May 2019 that a working draft solution for this problem was introduced by the name of Intersection Observer. It basically provides a way to asynchronously observe changes in the intersection of a target element with the document's viewport. While still a working draft the Intersection Observer API is already widely used and equips us with the mechanics to achieve performant scroll-into-view animations.

You can read more on how exactly the Intersection Observer works on the MDN web docs. There are a number of limitations or rather gotchas when it comes to using this new API in a React context. We are going to use a popular third-party React hook from the react-use package to abstract these problems away, so we can focus more on what we wanted to to in the first place, i.e. animate an element when it gets scrolled into view. The Intersection Observer is basically the guy telling GSAP when exactly to execute the animation. All we need to know is that the useIntersection hook we are going to use is powered by the new Intersection Observer API under the hood.

Enough talk, let's jump into code:

const Pricing: React.FC<Props> = () => {
  const [ tween, setTween ] = useState<GSAPTween>();
  const trigger = useRef<HTMLDivElement>(null);
  const simplyGreen = useRef<HTMLDivElement>(null);
  const shockinglyGreen = useRef<HTMLDivElement>(null);
  const businessGreen = useRef<HTMLDivElement>(null);
  const intersection = useIntersection(trigger, {
    root: null,
    rootMargin: '0px',
    threshold: 0.5,
  });

  useEffect(
    () => {
      if (intersection && intersection.isIntersecting) {
        setTween(
          gsap.to([ simplyGreen.current, shockinglyGreen.current, businessGreen.current ], 0.3, {
            opacity: 1,
            y: 0,
            ease: Sine.easeIn,
            stagger: 0.2,
          }),
        );
      } else if (tween && tween.paused) {
        tween.reverse();
      }
    },
    [ intersection ],
  );

  return (
    <div className={`Pricing`} ref={trigger}>
      <div className="Pricing__plan" ref={simplyGreen}>
        <div className="Pricing__subtitle">Simply Green</div>
      </div>
      <div className="Pricing__plan" ref={shockinglyGreen}>
        <div className="Pricing__subtitle">Shockingly Green</div>
      </div>
      <div className="Pricing__plan" ref={businessGreen}>
        <div className="Pricing__subtitle">Business Green</div>
      </div>
    </div>
  );
};
Fig2: Code for <Pricing /> component, i.e. grey section that is scrolled into view
  1. Create refs for the individuals elements that we want to animate. We are animating the pricing section consisting of a total of three plans. Since we want to tweak animation behaviour for each individual plan we create separate refs and allocate them accordingly (see L33, L36, L39).
  2. Create and allocate a ref for the wrapper component (see L32), i.e. the grey area that is being scrolled into view.
  3. Create an Intersection Observer using the useIntersection React hook provided by the react-use package (see L7-11). The treshold value of 0.5 is of interest here. It tells the observer not to ring the bell right away wenn the trigger is partially scrolled into the viewport. Rather, it instructs the observer to wait until a full 50% of the trigger is visible inside the viewport prior notifying GSAP to commence the animation.
  4. How does the communication between the Intersection Observer and GSAP work? The intersection object has an isIntersecting property telling GSAP when to start the animation (see L15).
  5. In case an intersection is not given, we have the chance to reverse the animation, i.e. set it into its initial state. Imagine after viewing the page we scroll all the way to the top. When we start scrolling the animation will be fired again when the intersection requirements are fulfilled. This is totally optional though. You could omit lines L24-L26 to achieve a more mature, less playful user experience. If you would do so, the animation would only trigger the first time the intersection is fulfilled and never again.
  6. We wrap the animation logic in a useEffect hook that is only ever re-run when the intersection object changes. That's necessary to avoid redundantly repeating the animation although the isIntersecting flag has remained unchanged (see L13).
  7. Last but not least importantly we capture the gsap animation object in a tween state. This allows us to keep track of the tween object and do something with it when another user interaction occurs. In the above example, the ability to call tween.reverse() in L25 is owed to the usage of state.

Add animation when element is put on or taken off the screen

Demo for adding animation when element is is placed onto or taken off the screen
Fig3: Animation triggered after creation of a new DOM element through user interaction

The other common use case to use animations is when you place an element on the screen, i.e. into the DOM, and likewise when you take it off again. The process is often triggered by user interaction just like a sidebar that is slided onto and off the screen upon clicking the menu icon. The big difference to the use case we studied before is that the sidebar, unlike the pricing section, is not rendered in the DOM tree right from the git-go. The user interaction triggers the creation of the DOM element. Therefore, we cannot simply take a pre-existing element and animate it from one state into another like we used to. Rather, we now have to listen to the various React lifecycle events and fire the animation when the element has been successfully placed into the DOM. Sounds familiar? That's exactly what react-transition-group (RTG) exposes. The secret is to understand that no one single library or package will suffice to deal with complex animation requirements. It is often teamwork that makes things possible. A team in which every member has its very own strengths and responsibilities. Just like the Intersection Observer would tell GSAP when to fire the animation in the previous use case, RTG takes over that responsibility here.

Another big take-away when it comes to using GSAP with React is that only elements that actually exist in the DOM tree can be animated 🤔. Sounds obvious, right? Just the implication this has on animations is not that trivial. It means that conditional rendering in the form of open && <Sidebar /> must be avoided in your TSX code. Imagine the open flag being switched from true to false, then all of a sudden the element would be destroyed while you're not be given notice or time rather to execute your leave-animation. It is just important to be aware of this fact and how to overcome this shortcoming, namely through RTG. Let's have a look.

const Sidebar: React.FC<Props> = ({ open }) => {
  const menuRef = useRef<HTMLUListElement>(null);
  const [ timeline, setTimeline ] = useState<TimelineLite>();

  useEffect(() => {
    setTimeline(gsap.timeline({ reversed: true }));
  }, []);

  return (
    <Transition
      in={open}
      timeout={500}
      mountOnEnter
      unmountOnExit={false}
      addEndListener={(node, done) => {
        const sidebarRef = { current: node };
        if (!timeline) return;

        if (open) {
          timeline
            .fromTo(sidebarRef.current, 0.5, { marginLeft: '-300px' }, { marginLeft: 0 })
            .fromTo(
              menuRef.current!.children,
              {
                scale: 0.5,
                opacity: 0,
              },
              {
                opacity: 1,
                scale: 1,
                duration: 1,
                delay: -0.5,
                ease: Elastic.easeInOut,
                stagger: { amount: 0.5 },
              },
            )
            .play()
            .then(done);
        } else {
          // timeline.reverse().then(done).then(() => timeline.clear());
          // or more subtle reverse function
          gsap.fromTo(sidebarRef.current, 0.5, { marginLeft: 0 }, { marginLeft: '-300px' }).then(done);
        }
      }}
    >
      <div className={`Sidebar`}>
        <ul className="Sidebar__menu" ref={menuRef}>
          <li className="Sidebar__item">Blog</li>
          <li className="Sidebar__item">About</li>
          <li className="Sidebar__item">Services</li>
          <li className="Sidebar__item">Contact</li>
        </ul>
      </div>
    </Transition>
  );
};
Fig4: Code for <Sidebar /> component
  1. Wrap your sidebar content with the <Transition> component from the RTG package.
  2. Instead of using conditional rendering as discussed earlier, provide the information when the element should be animated onto screen via the in prop (see L11).
  3. The mountOnEnter prop on L13 makes sure that the animation is only triggered through user interaction, i.e. click on menu icon. Otherwise, the component would be rendered and displayed on page load.
  4. The unmountOnExit={false} on L14 pertains to the second big take-away of using GSAP with React as previously mentioned. The moment a component is unmounted, it can no longer be referenced - still less be animated. That is why we keep the element around, we just push it off screen and leave it there. Clicking the menu icon again would not create a new element, but rather reuse the one that we parked outside the viewport.
  5. The addEndListener on L15 is the actual glue between React and GSAP. That's where the magic happens. While most React developers have used RTG before, the addEndListener is not typically a callback that most have stumbled upon. The first argument is the node that is wrapped by the <Transition /> component, i.e. the sidebar div. The second argument is a done callback that is used to inform React once we are done with our animation kung-fu (see L38, L40, L42).
  6. We capture an instance of a GSAP timeline on initial render using the useEffect hook with an empty update array (see L5-L7) and store the result in a React state object (see L3). This allows us to later reference the same timeline instance throughout the component.
  7. In L22 we grab all menu list items and stuff them into the GSAP timeline for the enter animation. That is possible because we created and allocated a ref to the menu ul on L47. Simply adding stagger: { amount: 0.5 } on L34 creates that super advanced staggering effect when the menu list items are animated making sure the user will take notice ✨.
  8. While that level of attention might be suitable for an enter animation, it may not be necessary for the leave animation. L40 and L42 present two alternatives on how you could design the latter. The first option is a mere reversal of the animation that you have played on enter. If that's what you want then you also need to make sure to clear() the timeline after the sidebar leave animation is completed. Otherwise, animations are stacked over time and clicking the menu icon mutliple times would yield a situation where GSAP tries to execute an animation multiple times - an undesireable side effect. If instead, you want a more subtle leave experience, you could fire a totally new GSAP animation without the stagger effect (see L42).

Recap

Let's recap! We have studied the two most common use cases you want to have animations for. The former is about performant and resource-efficient animations when a trigger element is scrolled into the viewport. The takeaway is to leverage the capabilities of the new Intersection Observer API to achieve the desired effect. The latter is about animating elements on and off the screen. An element may not exist when the page first renders, but only be created upon user interaction. The takeaway is to leverage the capabilities of react-transition-group (RTG) to achieve the desired effect. We have primarily discussed on what moving parts need to be combined to make animations with React and GSAP a bliss. We have discussed the corner cases and shortcomings and also how to overcome them. When you made it thus far, you can now start searching YouTube and Google for all type of GSAP tutorials - based on React or not. You are now equipped with the knowledge to port whatever GSAP magic you see into a React app.

Feedback is much appreciated. Code available at this Github repo.