Reveal animations on scroll with react-spring

Last update: 17 October, 2019
Table of contents

In this post, you’ll use the react-spring library to create reveal animations on scroll. I’m talking about simple animations like fade-ins and slide-ups. To determine if an element is visible, you’ll use the react-visibility-sensor. I will also mention some smaller libraries you can use that specialize in reveal animations—instead of react-spring—and I will show a solution with react-visibility-sensor and CSS transitions.

There are many libraries that specialize in scroll animations. A popular is aos at about 4.5kb, and another one is sal.js, which is under 1kb.

On the other hand, react-visibility-sensor is about 2.9kb, and react-spring is around 10kb. Besides that, neither of them has good tree-shaking. This means that you’ll end up with an additional 13kb in your final bundle size, no matter how much code you use. In fact, I tested this, and from the initial Create React App to the final version of this example, the bundle size increased by 14kb.

As a result, this example is useful if you’re already using react-spring for animations in your app. If you don’t, sal.js might be a better alternative. In the following link, you can find an example react component with sal.js

The plan

You can create repeatable animations with the Spring component from react-spring. While skimming through the documentation, I thought you can use it only for mounting animations. You specify a from and a to prop, and the component animates when first mounts on the screen. But later I noticed that the from prop is optional. Only the to prop is required.

But what will help us here is that you can change the to prop during runtime and the component will animate accordingly.

So, the plan is to:

  • Use react-visibility-sensor to determine if the element is visible or not,
  • Change the to prop (or more specifically, the opacity) of the Spring component during runtime, as shown below:
<Spring to={{ opacity: isVisible ? 1 : 0 }} />

Project Setup

GitHub repo with the completed example

The first thing you have to do is to create a new project with Create React App:

npx create-react-app react-spring-scroll
cd react-spring-scroll

Then, install the dependencies, and start the development server:

yarn add react-spring react-visibility-sensor
yarn start

After that, go to the src/App.js file, and add the following code. Pay attention to the highlighted lines:

src/App.js
import React from "react";
import { Spring } from "react-spring/renderprops";
import VisibilitySensor from "react-visibility-sensor";

// styles
const centeredStyles = {
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  flexDirection: "column",
  height: "100%",
};
const h2Styles = {
  fontSize: "82px",
};

const App = () => {
  return (
    <div>
      <div
        style={{
          ...centeredStyles,
          height: "100vh",
          backgroundColor: "pink",
        }}
      >
        <VisibilitySensor>
          {({ isVisible }) => (
            <Spring delay={300} to={{ opacity: isVisible ? 1 : 0 }}>
              {({ opacity }) => (
                <h2 style={{ ...h2Styles, opacity }}>Hello</h2>
              )}
            </Spring>
          )}
        </VisibilitySensor>
      </div>
    </div>
  );
};

export default App;

In the code above, we use two components. The Spring and the VisibilitySensor. They are both render prop components—meaning that they expose their functionality through function parameters. For example, the VisibilitySensor does this through the isVisible, and the Spring through the opacity.

From VisibilitySensor we use the isVisible boolean flag. We pass no other props to the component.

In the Spring component, we pass 2 props.

  • We pass a delay with a value of 300ms to give some time to the animation. We do that in case the page hasn’t loaded yet, or if there’s some delay with the scroll event.
  • We pass the to prop. If the component (heading 2) is visible, we pass to the to prop an object with an opacity property of 1. If it’s not visible, we change it to 0. Whenever that value changes, the component fades in or fades out accordingly.

To see the effect better, you’ll add now 2 more sections. Those sections will have slightly different reveal animations. Again pay attention to the highlighted lines:

src/App.js
import React from "react";
import { Spring } from "react-spring/renderprops";
import VisibilitySensor from "react-visibility-sensor";

// skipping styles...

const App = () => {
  return (
    <div>
      {/* skipping first section... */}
      {/* copy from here... */}
      <div
        style={{
          ...centeredStyles,
          overflow: "hidden",
          height: "100vh",
        }}
      >
        <VisibilitySensor partialVisibility>
          {({ isVisible }) => (
            <Spring
              delay={300}
              to={{
                opacity: isVisible ? 1 : 0,
                transform: isVisible
                  ? "translateX(0)"
                  : "translateX(200px)",
              }}
            >
              {(props) => (
                <h2 style={{ ...h2Styles, ...props }}>World</h2>
              )}
            </Spring>
          )}
        </VisibilitySensor>
      </div>
      <div
        style={{
          ...centeredStyles,
          height: "100vh",
          overflow: "hidden",
          backgroundColor: "#afd4d4",
        }}
      >
        <VisibilitySensor partialVisibility offset={{ bottom: -400 }}>
          {({ isVisible }) => (
            <Spring
              delay={300}
              to={{
                opacity: isVisible ? 1 : 0,
                transform: isVisible
                  ? "translateY(0)"
                  : "translateY(400px)",
              }}
            >
              {(props) => <h2 style={{ ...h2Styles, ...props }}>!!!</h2>}
            </Spring>
          )}
        </VisibilitySensor>
      </div>
      {/* until here... */}
    </div>
  );
};

export default App;

We’re doing almost the same thing. But instead of just fading in and out the elements we’re sliding them to the left in the “World” section and sliding them up in the “!!!” section. The overflow: "hidden" on the containers is there because we don’t want the heading elements expanding (overflowing) the page when we place them in their start position (for example, translateX(200px)). Be careful with the overflow, though; displaying the content is more important than some fancy animations.

I also added to every VisibilitySensor component the partialVisibility prop. By having that prop set to true, the following happens:

  • The component will become visible if a part of it is visible
  • The component will hide if all of it is out of the view.

By default, the partialVisibility is set to false. In this case, the behavior is the complete opposite of the above.

To see this clearly, add these lines to the h2Styles variable:

const h2Styles = {
  fontSize: "82px",
  color: "white",
  backgroundColor: "black",
  padding: "16px 32px",
};

If you now scroll up and down, you will notice something. The animations occur every time the elements become visible. In the next section, you’ll see how to animate them only once.

Enhance VisibilitySensor

If you want to animate the elements only when they first become visible, you’ll have to override the default component from react-visibility-sensor and implement an once prop. That prop will allow you to turn off the sensor after the first reveal. Create the VisibilitySensor.jsx component inside src/components:

src/components/VisibilitySensor.jsx
import React, { Component } from "react";
import PropTypes from "prop-types";
import VSensor from "react-visibility-sensor";

class VisibilitySensor extends Component {
  constructor(props) {
    super(props);
    this.state = {
      active: true,
    };
  }

  render() {
    const { active } = this.state;
    const { once, children, ...theRest } = this.props;
    return (
      <VSensor
        active={active}
        onChange={(isVisible) =>
          once &&
          isVisible &&
          this.setState({ active: false }, () =>
            console.log("turned the thing off!")
          )
        }
        {...theRest}
      >
        {({ isVisible }) => children({ isVisible })}
      </VSensor>
    );
  }
}

VisibilitySensor.propTypes = {
  once: PropTypes.bool,
  children: PropTypes.func.isRequired,
};

VisibilitySensor.defaultProps = {
  once: false,
};

export default VisibilitySensor;

We keep an active boolean flag in the component state. When the element first comes into view, and the once prop is true, we change the active state to false. By doing that, we turn off the sensor by passing to the active prop a value of false.

To use this, go back to your App.js and do the following:

  • Change the import of the VisibilitySensor from the node package to the custom component.
  • Add an once prop as shown below:
src/App.js
import VisibilitySensor from "./components/VisibilitySensor";

<VisibilitySensor once>

Now I’ll show you another solution that uses the VisibilitySensor and CSS transitions.

A solution with CSS transitions

You saw earlier that if you don’t want to use react-spring, you can instead use some smaller libraries that specialize in reveal animations. Now I’ll show you how to do reveal animations with CSS transitions and react-visibility-sensor.

The plan here is to swap the classes of the h2 elements (visible/hidden), and these classes, in turn, will trigger some CSS transitions. I choose transitions over animations because you can easily revert them—if the element changes state (visible/hidden) without completing the animation. That’s assuming you want repeated reveal animations, and you don’t want to use the once prop from VisibilitySensor.

Let’s start with the App.js component:

src/App.js
// skipping the other imports.
import "./App.css";

const App = () => {
  return (
    <div>
      <div
        style={{
          ...centeredStyles,
          height: "100vh",
          backgroundColor: "pink",
        }}
      >
        <VisibilitySensor>
          {({ isVisible }) => (
            <h2
              className={isVisible ? "fadeIn enter" : "fadeIn"}
              style={{ ...h2Styles }}
            >
              Hello
            </h2>
          )}
        </VisibilitySensor>
      </div>
      <div
        style={{
          ...centeredStyles,
          height: "100vh",
          overflow: "hidden",
        }}
      >
        <VisibilitySensor partialVisibility>
          {({ isVisible }) => (
            <h2
              className={isVisible ? "slideLeft enter" : "slideLeft"}
              style={{ ...h2Styles }}
            >
              World
            </h2>
          )}
        </VisibilitySensor>
      </div>
      <div
        style={{
          ...centeredStyles,
          height: "100vh",
          backgroundColor: "#afd4d4",
          overflow: "hidden",
        }}
      >
        <VisibilitySensor partialVisibility offset={{ bottom: -400 }}>
          {({ isVisible }) => (
            <h2
              className={isVisible ? "slideUp enter" : "slideUp"}
              style={{ ...h2Styles }}
            >
              !!!
            </h2>
          )}
        </VisibilitySensor>
      </div>
    </div>
  );
};

After that, you’ll have to write some CSS for those classes. Replace the contents of your App.css with the following:

src/App.css
.fadeIn {
  transition: opacity 1s ease-in-out;
  opacity: 0;
}
.fadeIn.enter {
  transition: opacity 1s ease-in-out;
  opacity: 1;
}
.slideLeft {
  transition: opacity 1s ease-in-out, transform 1s ease-in-out;
  opacity: 0;
  transform: translateX(100%);
}
.slideLeft.enter {
  transition: opacity 1s ease-in-out, transform 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
  opacity: 1;
  transform: translateX(0);
}
.slideUp {
  transition: opacity 1s ease-in-out, transform 1s ease-in-out;
  opacity: 0;
  transform: translateY(100%);
}
.slideUp.enter {
  transition: opacity 1s ease-in-out, transform 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
  opacity: 1;
  transform: translateY(0);
}
@media (print), (prefers-reduced-motion: reduce) {
  .fadeIn,
  .slideLeft,
  .slideUp {
    transition: none;
    opacity: 1;
    transform: none;
  }
}

At the end of the file, there is a prefers-reduced-motion media query that disables the animations if the user wishes so. They can enable this option from their operating system settings.

The last thing I want to show you is how to implement reveal animations if you use Server Side Rendering (SSR).

SSR with JavaScript disabled

The solutions you saw earlier work well for a client-side application with create-react-app. If you try to do the same in an application that does some SSR—let’s take, for example, Gatsby—you’ll see that we have the following problem. If you load the page with JavaScript disabled, your content will be hidden because the elements start with opacity: 0.

To solve this, you want to disable all reveal animations by default, and if JavaScript is available, you want to enable them again. You can do this by adding a no-js class to your body element and then writing some CSS that disables the animations when the no-js class is present. After that, you add a script, right after the body element, that will remove the no-js class from the body if JavaScript is enabled.

Let’s see how you can do that in Gatsby for the solution with the CSS transitions. Here’s a GitHub repo for the example in Gatsby if you want to take a look at the code.

In Gatsby, you can add a class in the body element with the setBodyAttributes method that’s available in the onRenderBody SSR API. Create a gatsby-ssr.js file at the root of the project and implement the onRenderBody method:

gatsby-ssr.js
export const onRenderBody = ({ setBodyAttributes }) => {
  setBodyAttributes({ className: "no-js" });
};

Next, you have to write the CSS that will disable the animations when that class is present:

body.no-js .fadeIn {
  opacity: 1;
}
body.no-js .slideLeft {
  opacity: 1;
  transform: none;
}
body.no-js .slideUp {
  opacity: 1;
  transform: none;
}

The last thing you have to do is add a script tag right after the body element. You can do this in Gatsby with the setPreBodyComponents method that’s available as a parameter of the onRenderBody API. As a result, you’ll have to change your gatsby-ssr.js to the following:

gatsby-ssr.js
import React from "react";

export const onRenderBody = ({
  setPreBodyComponents,
  setBodyAttributes,
}) => {
  setBodyAttributes({ className: "no-js" });
  setPreBodyComponents([
    <script
      dangerouslySetInnerHTML={{
        __html: `document.querySelector('body').classList.remove('no-js')`,
      }}
    />,
  ]);
};

You can achieve the same result in the react-spring example by adding a .reveal class in the h2 elements and writing the following CSS:

body.no-js .reveal {
  opacity: 1 !important;
  transform: none !important;
}

Keep an eye for react-spring version 9 because it will have a method you can use to disable animations.

Final Words

Let’s summarize now some key points you saw throughout this post:

  • You can determine if an element is visible with react-visibility-sensor.
  • You can use that information to animate elements with react-spring or with CSS transitions.
  • You saw how to adjust the VisibilitySensor to fire only once to avoid repeatable reveal animations.
  • You can use some lightweight libraries that specialize in reveal animations instead of a heavier library like react-spring. I also gave a GitHub link to an example React component with sal.js.
  • Finally, you saw how to implement the above in a client-side app with create-react-app and in an SSR” app with Gatsby.

Other things to read

Popular

Previous/Next