loading...

Building Custom Scroll Animations Using React Hooks

chriseickemeyergh profile image Chris Eickemeyer Updated on ・3 min read

Before you reach for a library to implement scrolling animations in React, it's not too difficult to build them yourself and you may end up having more control & flexibility over the specific effects you want to implement.

The Reacts hooks we're going to use here are useRef, useLayoutEffect, and useState. useRef will be for accessing individual DOM elements, useLayoutEffect for running code upon page mount synchronously after all DOM mutations (so that our refs are not undefined), and useState to update our component state (useReducer works well for this case too).

First off, we need to calculate the vertical position of an element from the top of the document. We can use the top property from the getBoundingClientRect() method on our refs to do this, like so:

 const ourRef = useRef(null);
 const topPosition = ourRef.current.getBoundingClientRect().top;  //some number

Next we want to compare this value to the sum of the window properties window.scrollY + window.innerHeight. When topPosition is less than these added values, we can trigger our CSS animation/transition:

if(topPosition < window.scrollY + window.innerHeight) { 
      //do something here 
 }

To actually use this code, we need to listen for the scroll event on the window and run it when the user scrolls down the page. We can assign the event listener to the window in our useLayoutEffect hook and track scroll changes:

 const ourRef = useRef(null);

  useLayoutEffect(() => {
    const topPosition = ourRef.current.getBoundingClientRect().top;
    const onScroll = () => {
      const scrollPosition = window.scrollY + window.innerHeight;
     if(topPosition < scrollPosition) { 
     // trigger animation 
       }
    };

    window.addEventListener("scroll", onScroll);
    return () => window.removeEventListener("scroll", onScroll);
    /* 
       remove the event listener in the cleanup function 
       to prevent memory leaks
    */
  }, []);

Now, once topPosition < scrollPosition is true, we can change our state to trigger our animations. While we could declare a state variable for each animation we want to trigger, I think it's best to group them into one state variable in the form of an object, like this:

const [show, doShow] = useState({itemOne: false, itemTwo: false, itemThree: false}) // ...etc

It's important to note that when we update any property in our state object here, we need to compute the new value using our previous state. If we only use the object spread operator to update our state, we'll need to include our state in our array of dependencies in the useLayoutEffect hook, and this will cause unnecessary renders (tons and tons of them).

//do this
doShow(state => ({...state, itemOne: true})

//not this
doShow({...show, itemOne: true})

To conditionally add styles based on our state, we can either add a CSS class name using conventional CSS or change the state of a prop when using a CSS-in-JS solution. I'll be using the latter here.

Using Styled Components, here's what our JSX might look like if we want to slide a couple of elements from left to right on scroll:

const [show, doShow] = useState({itemOne: false, itemTwo: false, itemThree: false})
const ourRef = useRef(null),
      anotherRef = useRef(null),
      refThree = useRef(null);
...
...
return (
<>
  <Div animate={show.itemThree} ref={refThree} />
  <Div animate={show.itemTwo} ref={anotherRef} />
  <Div animate={show.itemOne} ref={ourRef} />
</>
)

// component we are animating
const Div = styled.div`
  transform: translateX(${({ animate }) => (animate? "0" : "-100vw")});
  transition: transform 1s;
  height: 900px;
  width: 300px;
  background-color: red;
/* i'm using destructuring on the prop above */
`;

/* 
adding a conventional CSS class could look something like this:
<div ref={ourRef} className={`defaultClass${show.itemOne ? " addedClass" : ""}`} /> 
*/

And that's pretty much it. Implementing this yourself will give you total control over when to start your animations and what your animations can be, without the extra bundle size of an animating library. I've put the full example below, and here's the CodeSandbox interactive example.

Full example:

import React, { useLayoutEffect, useRef, useState } from "react";
import styled from "styled-components";

const App = () => {
  const [show, doShow] = useState({
    itemOne: false,
    itemTwo: false,
    itemThree: false
  });
  const ourRef = useRef(null),
        anotherRef = useRef(null),
        refThree = useRef(null);

  useLayoutEffect(() => {
    const topPos = element => element.getBoundingClientRect().top;
   //added to reduce redundancy
    const div1Pos = topPos(ourRef.current),
          div2Pos = topPos(anotherRef.current),
          div3Pos = topPos(refThree.current);

    const onScroll = () => {
      const scrollPos = window.scrollY + window.innerHeight;
      if (div1Pos < scrollPos) {
        doShow(state => ({ ...state, itemOne: true }));
      } else if (div2Pos < scrollPos) {
        doShow(state => ({ ...state, itemTwo: true }));
      } else if (div3Pos < scrollPos) {
        doShow(state => ({ ...state, itemThree: true }));
      }
    };

    window.addEventListener("scroll", onScroll);
    return () => window.removeEventListener("scroll", onScroll);
  }, []);
  return (
    <>
       <Div animate={show.itemThree} ref={refThree} />
       <Div animate={show.itemTwo} ref={anotherRef} />
       <Div animate={show.itemOne} ref={ourRef} />
    </>
  );

const Div = styled.div`
  transform: translateX(${({ animate }) => (animate ? "0" : "-100vw")});
  transition: transform 1s;
  height: 900px;
  width: 300px;
  background-color: red;
`;

};

Discussion

markdown guide