Andrew Nessin

Understanding The Intersection Observer API

Published on

In this post, I'll explain the concept behind the browser's native IntersectionObserver API and how to use it. I'll also show a real-world use case in React - infinite scrolling.

Prerequisites

This post assumes familiarity with the following topics:

  1. React, particularly the hooks - useEffect and useRef.
  2. Basic understanding of viewports.
  3. Callbacks in JavaScript.
  4. Basic CSS.

Visualizing The Concept

I have built an interactive demo below to help me explain the Intersection Observer API. Imagine a web page as the box with a dashed outline as below. Within it, we have an element represented by the balloon.

A webpage.

Also, imagine the browser's viewport as a box with a solid outline as below. We need a scrollbar to visualize how this works, hence I've made the page longer than the viewport.

The viewport.

Use the scrollbar next to the page to scroll the page and see what you can understand. This is just to help you get familiar with the idea before I explain it in detail. 😊

Status
Not Observing

Click the "Start Observing" button to begin. The 🀨 emoji will appear, indicating that we're in the observing state. Now, use the slider on the right to scroll the page and watch for the 😍 emoji to appear at key moments. Take note of the exact states when it shows up.

Based on your observation, see if you can answer the quiz below. You don't have to get them right, the important thing is that you make an attempt. This will help you understand the concept better.

  • Which of the following are true? (Check all that apply)

The 😍 emoji appears at 3 distinct states:

  • As soon as we start observing.
  • When the balloon first enters the viewport when we're observing.
  • When the balloon first exits the viewport when we're observing.

Technically, the balloon in our example is called the target (it's the element we want to observe), and the viewport is called the root.

The first state indicates that we started observing the target. This can be used to learn the initial intersection status, before any scrolling occurs.

The second state indicates that at least one pixel of the target overlapped with at least one pixel of the root; we say the target intersects with the root. The third state indicates that no pixels of the target overlapped with the root; we say the target doesn't intersect with the root.

Let's look at some code!

The API

Here's an implementation of the above behaviour in React. Play around by scrolling the page and see if you can figure out what's going on. I'll break down the parts right below the playground. Try scrolling the balloon in and out of view and compare it with the interactive demo above. Clear the console and reload the page to observe the behavior from the beginning.

import React from "react";
import "./styles.css";

function App() {
  const observedElemRef = React.useRef();

  React.useEffect(() => {
    const observer = new IntersectionObserver(() => {
      console.log("Interesting");
    });

    observer.observe(observedElemRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="wrapper">
      <p>
        Scroll down till you see the balloon and have an eye on the console logs as
        you scroll!
      </p>
      <img
        ref={observedElemRef}
        src="https://www.andrewnessin.com/images/balloon.png"
        width={60}
        height={60}
      />
    </div>
  );
}

export default App;

We create an Intersection Observer using its constructor:

const observer = new IntersectionObserver(callback);

The callback will be invoked by the observer when something interesting happens. Our callback simply logs "Interesting". In the interactive demo, this callback invocation was represented by the emoji 😍 appearing.

() => {
console.log("Interesting");
};

How does the observer know what is interesting? We have two knobs to tell the observer what we are interested in.

The First Knob: Specifying the target

The first one is to specify the target:

observer.observe(observedElemRef.current); //This is the balloon

Now that the observer is made aware of the target, it starts observing it. This is represented by the "🀨 Start Observing" button in the interactive demo.

We can make the same three observations from this code as in the interactive demo:

  • "Interesting" is logged when the page first loads. (This actually happen when we first call the observe function on the observer. It just appears to happen on page load.)
  • It's logged when the first pixel of the target enters the viewport.
  • It's also logged when last pixel of the target exits the viewport.

This is the default behaviour of the IntersectionObserver when we do not use the second knob.

The entries Argument

Our callback is invoked with the entries argument by the observer. This argument is an array where each item describes the intersection event that caused the callback to be invoked. We ignored the event in the previous example. In the following example, we use it to trigger an animation when the target enters the viewport.

import React from "react";
import "./styles.css";

function App() {
  const observedElemRef = React.useRef();
  const [isBalloonVisible, setIsBalloonVisible] = React.useState(false);

  React.useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      const [entry] = entries;
      setIsBalloonVisible(entry.isIntersecting);
    });

    observer.observe(observedElemRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="wrapper">
      <p>
        Scroll down till you see the balloon and have an eye on the balloon's
        size!
      </p>
      <img
        ref={observedElemRef}
        src="https://www.andrewnessin.com/images/balloon.png"
        width={60}
        height={60}
        className={`balloon ${isBalloonVisible ? "scaledUp" : ""}`}
      />
    </div>
  );
}

export default App;

Let's look at the entries argument:

const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
setIsBalloonVisible(entry.isIntersecting);
});

We pluck the one and only item from the entries array. How do we know it has only one element? Because we have observed only one element. (When we observe multiple elements or tweak the second knob in certain ways, the entries argument can have multiple events. We won't cover that in this blog post.)

Each intersection event is described by a set of properties. For example, the isIntersecting property tells us if the target entered or exited the viewport when the event occured. We conditionally apply the scaleUp CSS class whenever the target (the balloon in our case) is visible. This triggers the animation exactly when the target enters the viewport! We detect visibility by checking if isIntersecting is true. When it's false, it means the target is not visible (in other words, it's not intersecting).

The Second Knob: The Options Object

The second knob to control what we're interested in is the second argument to the IntersectionObserver constructor. It's an object with three optional properties.

const options = {
root: document.querySelector("#scrollArea"),
rootMargin: "0px",
threshold: 1.0,
};

We'll look at them in turn.

root

Root is the element that acts as the viewport for the intersection calculation. In the above examples we never set the root explicitly; so it defaulted to the browser viewport. In the context of the playground, the default root ended up being the iframe's viewport. The below sandbox shows an example where the root is a different scroll container.

import React from "react";
import "./styles.css";

function App() {
  const observedElemRef = React.useRef();
  const rootRef = React.useRef();
  const [isBalloonVisible, setIsBalloonVisible] = React.useState(false);

  React.useEffect(() => {
    const callback = (entries) => {
      const [entry] = entries;
      setIsBalloonVisible(entry.isIntersecting);
    };

    const options = {
      root: rootRef.current,
    };

    const observer = new IntersectionObserver(callback, options);
    observer.observe(observedElemRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="wrapper">
      <div ref={rootRef} className="scrollArea">
        <div className="scrollContent">
          <p>
            Scroll down till you see the balloon and have an eye on the balloon's
            size as you scroll!
          </p>
          <img
            ref={observedElemRef}
            src="https://www.andrewnessin.com/images/balloon.png"
            width={60}
            height={60}
            className={`balloon ${isBalloonVisible ? "scaledUp" : ""}`}
          />
        </div>
      </div>
    </div>
  );
}

export default App;

rootMargin and threshold

rootMargin and threshold adjust the boundaries of the root and the target, respectively, used to calculate the intesection. I've added additional controls to the following interactive demo to show how these two properties work. Play around to see what you discover. 😊

0

0px

0px

0px

0px

Status
Not Observing

Tweak the root margin and the threshold values before observing to alter the edges used in the intersection calculation. Root margin can take values for all 4 edges like the normal margin property, I've showed only the bottom edge to keep it simple. The threshold can also be set to any decimal value between 0 and 1. I have restricted it to keep it simple.

In our previous examples, the default rootMargin was 0 on all sides and the default threshold was 0. Let's consider a different example using the above demo. Set the threshold to 1.0 (leaving the rootMargin at 0) and start observing. Now if you scroll, we won't see a reaction when the first pixel of the balloon enters the viewport. Instead, we see a reaction when the last pixel enters the viewport.

As another example, stop observing, set the rootMargin to 40px, threshold to 0 and scroll the page to the top. Now start observing and start scrolling the balloon into view. The reaction will trigger way before the balloon enters the viewport. More specifically, the reaction triggers when the first pixel of the balloon touches the 40px margin's edge.

An Exercise

In our previous example the balloon scaled up too early, before it was fully in view. It appears as though the balloon is peeking from below.

What if our goal is to clearly show the balloon is scaling up?

I've added the same sandbox below. Can you try to use the options we just learned about to achieve this?

import React from "react";
import "./styles.css";

function App() {
  const observedElemRef = React.useRef();
  const [isBalloonVisible, setIsBalloonVisible] = React.useState(false);

  React.useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      const [entry] = entries;
      setIsBalloonVisible(entry.isIntersecting);
    });

    observer.observe(observedElemRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="wrapper">
      <p>
        Scroll down till you see the balloon and have an eye on the balloon's
        size!
      </p>
      <img
        ref={observedElemRef}
        src="https://www.andrewnessin.com/images/balloon.png"
        width={60}
        height={60}
        className={`balloon ${isBalloonVisible ? "scaledUp" : ""}`}
      />
    </div>
  );
}

export default App;

Solution

The solution is to add a threshold when creating the IntersectionObserver:

const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
setIsBalloonVisible(entry.isIntersecting);
},
//use the second argument to set the threshold
{
threshold: 1,
}
);
import React from "react";
import "./styles.css";

function App() {
  const observedElemRef = React.useRef();
  const [isBalloonVisible, setIsBalloonVisible] = React.useState(false);

  React.useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        setIsBalloonVisible(entry.isIntersecting);
      },
      {
        threshold: 1,
      }
    );

    observer.observe(observedElemRef.current);

    return () => observer.disconnect();
  }, []);

  return (
    <div className="wrapper">
      <p>
        Scroll down till you see the balloon and have eye on the balloon's size!
      </p>
      <img
        ref={observedElemRef}
        src="https://www.andrewnessin.com/images/balloon.png"
        width={60}
        height={60}
        className={`balloon ${isBalloonVisible ? "scaledUp" : ""}`}
      />
    </div>
  );
}

export default App;

Infinite Scrolling

Here's an example of how we can achieve the infinite scrolling effect using the Intersection Observer.

import React from "react";
import "./styles.css";
import Spinner from "./Spinner";

function App() {
  return (
    <div className="wrapper">
      <header className="header"></header>
      <Feeds />
    </div>
  );
}

function Feeds() {
  const [numFeeds, setNumFeeds] = React.useState(4);

  const [isLoading, setIsLoading] = React.useState(false);
  const endOfListElementRef = React.useRef();

  React.useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      const [entry] = entries;
      if (entry.isIntersecting) {
        loadMore();
      }
    });

    observer.observe(endOfListElementRef.current);

    return () => observer.disconnect();
  }, []);

  //Mimicking a network request
  function loadMore() {
    setIsLoading(true);
    window.setTimeout(() => {
      setNumFeeds((numFeeds) => numFeeds + 2);
      setIsLoading(false);
    }, Math.random() * 700 + 500);
  }

  return (
    <div className="feedsWrapper">
      {range(numFeeds).map((num) => {
        return (
          <div className="feed" key={num}>
            {num}
          </div>
        );
      })}
      <div className="spinnerWrapper">{isLoading && <Spinner />}</div>
      <div ref={endOfListElementRef}></div>
    </div>
  );
}

//Simplified version of the range function to serve the purpose of this example
const range = (end) => [...Array(end).keys()];

export default App;

Here, the target is a zero-height element positioned at the end of the viewport:

A browser snapshot showing the zero-height element at the end of the viewport and its corresponding div element in the developer tools console

We can position this element anywhere in the DOM to control when we make the network request.

This example only focuses on the Intersection Observer API. In the real-world, we also have to handle error states and avoid duplicate network requests. This can be done by hiding the target in the error and loading states.

Conclusion

I hope this helped you get a better understanding of how the API works 😊! Head over to the official docs to explore further. The API supports multiple threshold levels, provides the exact intersection ratio, and much more. You can also find a list of use cases at the beginning of the pageβ€”one of them being infinite scrolling.

Andrew Nessin

Blog template created by Josh W. Comeau. Check out The Joy of React to learn how to build dynamic React apps like this one!