Performing scroll-triggered animations using Intersection Observer API with React

In this guide, we will walk through how to create powerful scroll-triggered animations using the browser Intersection Observer API by building a simple website with a sticky back-to-top button. The Intersection Observer API provides an asynchronous way of detecting changes in the visibility of a DOM Element within a parent element or the document viewport.

Before the implementation of the Intersection Observer API, developers detect whether an element is visible in the DOM by calling the getBoundingClientRect method of the element. This method runs synchronously on the main thread and can cause serious performance issues.

The Intersection Observer API is used internally by animation libraries like Framer Motion for scroll animations.

Prerequisites

We recommend the following prerequisites before getting started.

Getting Started

In this section, we will bootstrap a simple React application using Vite and install other dependencies.

npx create vite@latest intersection-observer -- --template react
cd intersection-observer
npm install

Install the Tailwind CSS library and its dependencies and configure them to help with styling.

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Add the path to your React component files where tailwind CSS classes will be used in the tailwind.config.js.

module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Delete all existing styles and add the tailwind directive to your index.css file.

@tailwind base;
@tailwind components;
@tailwind utilities;

Start the Vite development server and visit the URL logged on your terminal, which defaults to localhost:5173.

npm run dev

Building the React Application

Next up, we will be breaking the application down by creating three simple React components and a custom hook. We will be creating a Header, BackToTop and ChevronUpIcon React components. The BackToTop component becomes visible when some part of the Header component disappears as the page is scrolled down and when clicked, scrolls up to the Header component. We will also create a custom React hook that utilizes the Intersection Observer API to check for the presence of an element in the viewport.

mkdir src/components
touch src/components/Header.jsx
touch src/components/BackToTop.jsx
touch src/components/ChevronUpIcon.jsx
mkdir src/hooks/usePresence.js -p

Creating the Header component

Inside our src/components/Header.jsx file:

import { forwardRef } from "react";

const Header = forwardRef((props, ref) => {
  return (
    <header
      ref={ref}
      className="p-3 flex items-center bg-slate-600"
      {...props}
    >
      <h2 className="text-md font-semibold text-white">Observer</h2>
    </header>
  );
});

export default Header;

Creating the ChevronUpIcon component

Inside our src/components/ChevronUpIcon.jsx file:

const ChevronUpIcon = (props) => {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      strokeWidth={1.5}
      stroke="currentColor"
      {...props}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M4.5 15.75l7.5-7.5 7.5 7.5"
      />
    </svg>
  );
};

export default ChevronUpIcon;

Creating the BackToTop component

Inside your src/components/BackToTop.jsx file:

import ChevronUpIcon from "./ChevronUpIcon";

const BackToTop = ({ isVisible, onClick }) => {
    if(!isVisible) {
        return <div />
    }

  return (
      <div
        role='button'
        className="fixed w-9 h-9 lg:w-10 lg:h-10 p-1 bottom-5 right-4 text-white bg-slate-500 rounded-full z-40 shadow-sm flex items-center justify-center"
        onClick={onClick}
      >
        <ChevronUpIcon className="w-6 h-6 md:h-7 md:w-7" />
      </div>
  );
};

export default BackToTop;

In the BackToTop component, if the isVisible prop is true, we render the main content else we render an empty div. We also passed an onClick prop to the onClick attribute.

Creating the usePresence custom hook

Inside your src/hooks/usePresence.js file:

import {useEffect, useState} from 'react';

const usePresence = (ref, options = {}) => {
    const [isEntering, setIsEntering] = useState(false);

    useEffect(() => {
        const observer = new IntersectionObserver((entries) => {
            setIsEntering(entries[0].isIntersecting);
        }, options)

        if(ref.current) {
            observer.observe(ref.current);
        }
        return () => {
            observer.disconnect();
        }
    }, [ref])

    return isEntering;
}

export default usePresence;

There is quite a bit happening in the code above, so let's walk through it:

  1. We set up a state (isEntering) for tracking the current visibility of the element ref which is passed as a parameter to the usePresence hook.

  2. Inside useEffect, an instance of IntersectionObserver is created with a callback function that is always called asynchronously when the position of the element changes and options.

  3. Inside this callback, the isEntering state is updated using the entries argument.

  4. We start observing for changes in the position of the element by calling the observe method of the IntersectionObserver instance.

  5. Once the component using this hook is unmounted, we stop observing for changes in the position of this element by calling the disconnect method of the IntersectionObserver instance.

  6. The state isEntering is returned to the caller of this hook.

Updating our App component

Inside your src/App.jsx file, update the existing code:

import { useRef } from "react";
import Header from "./components/Header";
import BackToTop from "./components/BackToTop";
import usePresence from "./hooks/usePresence";


function App() {
  const ref = useRef(null);
  const headerIsVisible = usePresence(ref, {threshold: 0.5});

  const handleClick = () => {
    if(ref.current) {
        ref.current.scrollIntoView({
          behavior: "smooth",
          block: "center"
        });

    };
  }

  return (
    <div>
      <Header ref={ref} />
      <BackToTop isVisible={!headerIsVisible} onClick={handleClick}  />
      <main className="min-h-[102vh]" />
    </div>
  )
}

export default App

Again, Let's break down the code above:

  1. We create a React Ref and pass it to the Header component to get access to the root DOM node.

  2. We pass this Ref to the usePresence hook and start tracking for changes in the visibility of the header element, we also passed in an IntersectionObserver option called threshold which is set to 0.5. This option means that the IntersectionObserver callback will be executed when the visibility of the header is 50%. The threshold option also accepts an array of numbers.

  3. We create a handleClick handler and passes it to the BackToTop component which is called when clicked to scroll back to the header component.

  4. The headerIsVisible variable returned by the usePresence will be true if more than 50% of the header element is visible and false otherwise. We display the BackToTop component when the headerIsVisible variable is false.

We now have a functional scroll-triggered animation for this simple website using Intersection Observer API. You can clone this github repo https://github.com/Chimise/intersection-observer if your app is not working as expected.