How to create scroll animations with just 1 React hook (Part 1)

Seif Ghezala's photo
Seif Ghezala
Updated 2023-11-13 · 5 min
Table of contents

This is a series of articles where we learn a few techniques to create scroll animations with just 1 React hook.

In this first part, we will use the scroll event to achieve that.

Here’s a preview of what we will achieve by the end of the article:

Video poster

This technique can be summarized in 2 steps:

1- Detect when the element we want to animate is in the viewport.
2- Trigger an animation on that element.

Before diving into these steps, let’s first have a basic setup to work with.

Basic setup

Let’s set up a basic React page with 2 elements with Fade Left and Fade Right sections that will animate later:

// index.js
function AnimatedSection({ children }) {
return (
<section>
<div className="rectangle">
<h2>{children}</h2>
</div>
</section>
);
}
function App() {
return (
<div className="App">
<header>
<h1>Scroll animations - Technique #1</h1>
</header>
<AnimatedSection>Fade left</AnimatedSection>
<AnimatedSection>Fade right</AnimatedSection>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
/* index.css */
@import url("https://fonts.googleapis.com/css?family=Indie+Flower&display=swap");
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: "Indie Flower", cursive;
color: #ffffff;
background-color: #000000;
background-image: url(some image pattern);
}
header {
width: 100%;
height: 100vh;
background-color: #000000;
display: flex;
justify-content: center;
align-items: center;
}
h1 {
font-weight: bold;
margin: 0;
}
.App {
text-align: center;
display: flex;
flex-direction: column;
}
section {
display: flex;
margin: 200px 50px;
}
section .rectangle {
display: flex;
justify-content: center;
align-items: center;
width: 120px;
height: 200px;
padding: 10px;
border: 10px solid rgb(255, 255, 0);
border-radius: 3px;
background-color: #000000;
}
section:nth-child(odd) {
justify-content: flex-start;
}
section:nth-child(even) {
justify-content: flex-end;
}
Video poster

Now that we have a basic page setup, let’s see how we can animate this 🙅‍♂️.

Step #1: is the element in the viewport? 👀

The viewport is the portion of the page visible to us in the browser window. Any DOM element has an interesting function: Element.getBoundingClientRect(). According to MDN, the returned object contains various information about the element’s position relative to the viewport.

MDN's Explanation of Element.getBoundingClientRect()
MDN's Explanation of Element.getBoundingClientRect()

What do we see?

  • The blue rectangle represents a DOM element.
  • Top is the distance of element’s top from viewport’s top.
  • Bottom is the distance of the element’s bottom from the viewport’s top.
  • Left is the distance of the element’s left side from the viewport’s left side.
  • Right is the distance of the element’s right side from the viewport’s right side.
Element’s bottom at 20px from the viewport’s bottom
Element’s bottom at 20px from the viewport’s bottom

Element’s bottom at 20px from the viewport’s bottom

Since getBoundingClientRect().bottom is the distance between the element’s bottom and the viewport’s top side, we can use this formula to calculate the distance to the bottom of the viewport:

Distance to bottom = window.innerHeight - getBoundingClientRect().bottom

To make this easy to use, we can create a custom hook, useIsInViewport, that calculates this measure on scroll and returns whether it’s equal to 20px:

function useIsInViewport(ref) {
const [isInViewPort, setIsInViewport] = useState(false);
useEffect(() => {
function handleScroll() {
const { bottom } = ref.current.getBoundingClientRect();
return setIsInViewport(window.innerHeight - bottom > 20);
}
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [ref, isInViewPort]);
return isInViewPort;
}

Step #2: animation time 🧙‍♂️

From the names of the 2 elements in our page, you probably guessed what animations we want. The first element will fade in from the right and second element will fade in from the left.

A CSS animation is basically a transition of the styles of an element from phase A to phase B.

To create an animation, we need to define what these phases are, the transition effect, and duration.

The styles of each element should therefore be based on whether it is in the viewport. If the element is not the viewport, we return the styles of phase A. If the element is in the viewport, we return the styles of phase B.

We can achieve that by making AnimatedSection accept a getStyles function and call it with isInViewport to get the element’s styles:

function AnimatedSection({ getStyles, children }) {
const elementRef = React.useRef();
const isInViewport = useIsInViewport(elementRef);
return (
<section style={getStyles(isInViewport)}>
<div className="rectangle" ref={elementRef}>
<h2>{children}</h2>
</div>
</section>
);
}
AnimatedSection.propTypes = {
getStyles: PropTypes.func.isRequired,
children: PropTypes.element,
};

You can learn about all the possible transition effects here. We will use a 1 second ease-in transition effect. This will make our animation have a slow start.

The opacity CSS property allows to define the visibility of an element on a scale of 1. To make an element fade in, it needs to go from on opacity of 1 to an opacity of 0.

One way of moving element, is to use the CSS property transform. This property allows to set a translate an element by a given percentage or number of pixels. You can check it out in detail here.

To make an element translate from right to left, we make it go from not having a transform applied to a translate(100%) transform.

Similarly, to make an element translate from left to right, we make it go from not having a transform applied at all to a translate(-100%) transform.

What’s left now is to define these animations and pass them to their respective components:

function App() {
const getFadeLeftStyles = isfadeLeftInViewPort => ({
transition: 'all 1s ease-in',
opacity: isfadeLeftInViewPort ? '1' : '0',
transform: isfadeLeftInViewPort ? '' : 'translateX(100%)'
});
const getFadeRightStyles = isfadeRightInViewPort => ({
transition: 'all 1s ease-in',
opacity: isfadeRightInViewPort ? '1' : '0',
transform: isfadeRightInViewPort ? '' : 'translateX(-100%)'
});
return (
<div className="App">
<header>
<h1>Scroll animations - Technique #1</h1>
</header>
<AnimatedSection getStyles={getFadeLeftStyles}>Fade left</AnimatedSection>
<AnimatedSection getStyles={getFadeRightStyles}>Fade right</AnimatedSection>
</div>
)

Recent articles

Guide to fast Next.js

Insights into how Tinloof measures website speed with best practices to make faster websites.
Seif Ghezala's photo
Seif Ghezala
2024-01-15 · 13 min