Coastal Media Brand

The Intersection Observer API is a native API provided by modern browsers. It empowers us to detect the visibility of an element relative to the viewport of the browser — or in some cases, relative to its containing element — with the aim of performing a certain action.

The react-intersection-observer package provides a way to implement this API in a React project. In this guide, we will explore how to utilize this library to monitor the visibility of different sections on a webpage and dynamically execute actions on the page header.

Jump ahead:

To follow along with this tutorial, we recommend you have a basic understanding of React.

Now, let’s dive right in and get started!

Consider a simple one-page site that I’ve put together:

Simple Single Page Application With Black Header Containing Four Menu Items. User Shown Clicking On Each Item To Scroll To Corresponding Page Section — Black Home Page, Teal About Page, Purple Project Page, Blue Contact Page

You can find the demo and source code on CodeSandbox.

In the showcased demo, a click on the menu item results in a seamless scroll to the corresponding page section.

Many single-page websites with a setup similar to this demo site allow users to scroll manually through different page sections while dynamically activating the corresponding menu items. In certain cases, websites even alter the header’s background color as the user scrolls past a specific section.

To accomplish all of these functionalities, we can leverage the power of the Intersection Observer API. The following GIF illustrates the final project:

User Shown Scrolling Through Same Single Page Application With Header Now Dynamically Updating To Display White Or Black And Highlight Menu Items Depending On Section ShownUser Shown Scrolling Through Same Single Page Application With Header Now Dynamically Updating To Display White Or Black And Highlight Menu Items Depending On Section Shown

We can now dive into the details.

To better understand the react-intersection-observer package, we will first explore how detecting element visibility worked before the Intersection Observer API, then take a look at the API’s underlying functionality. Jump ahead within this section:

Detecting element visibility prior to the Intersection Observer API

Before the Observer API was introduced, developers used to manage element visibility by listening for scroll events and then triggering a callback function to handle element detection as users scrolled through the page:

window.addEventListener('scroll', function (event){
 // Do something every time the user scrolls!
}

When using the traditional scroll event-based detection method, the event listener continuously fires for every scroll action and operates on the main thread. As a result, the system is prone to performance inefficiencies.

In contrast, the Intersection Observer API offers a solution that asynchronously observes changes and executes code off the main thread. Unlike the scroll event approach, it triggers a callback only when the observed element enters or leaves the viewport.

The way the Intersection Observer API works makes it more performant and preferable for handling element visibility efficiently. You can also read about some other use cases for this API, including lazy loading of images, infinite scrolling, and scroll-based animations.

Creating an IntersectionObserver

To monitor the visibility of an element within a parent or ancestor element, we’ll follow these steps:

  1. Obtain a reference to the target element
  2. Initialize a new IntersectionObserver instance by creating an observer and providing a callback function
  3. Use the observe() method on the observer and pass the target element as an argument

In its simplest form, here is the code implementation of the above steps:

const observer = new IntersectionObserver(callback);

const targetElement = document.querySelector("...");
observer.observe(targetElement);

The callback function of the observer will be triggered when a specified threshold is surpassed. For now, you can understand this threshold to be when the target element enters or exits the visible portion of the screen.

Observing multiple elements with the IntersectionObserver

In our project, we want to monitor intersection events on multiple sections of the page instead of just one. Therefore, we need to make some modifications to the code mentioned above.

First, we will obtain references to all the target sections. Then, we will iterate over each section and observe them individually.

Here is the updated code:

const observer = new IntersectionObserver(callback);

const targetElements = document.querySelectorAll("...");

targetElements.forEach((element) => {
  observer.observe(element);
}); 

Applying the Intersection Observer API in React

Now, let’s implement the intersection logic in our React project. In the starter project, the components/Page.js file is responsible for handling each section:

const Page = ({ ... }) => {
  // ...
  return (
    <section id={title}>
      {/* ... */}
    </section>
  );
}

export default Page;

To monitor the visibility of the section element, we will first obtain a reference to it. Then, we will incorporate the intersection logic within a useEffect Hook. We can do all of that within the parent App component:

import { useEffect } from "react";
// ...

function App() {

  useEffect(() => {
    const targetSections = document.querySelectorAll("section");

    const observer = new IntersectionObserver((entries) => {
      console.log(entries);
    });

    targetSections.forEach((section) => {
      observer.observe(section);
    });
  }, []);

  return (
    // ...
  );
}

export default App; 

We have utilized the querySelectorAll() method to obtain references to all the target <section> elements. However, in a later part of the lesson, you will learn how to achieve the same using React’s useRef Hook.

If you observe the callback function closely, you will notice that it takes an entries parameter, which returns an array of objects containing various details associated with each target section:

Screenshot Of Developer Console With Red Arrow Labeled With Number One In Red Circle Pointing To Isintersecting Property With True Value And Red Arrow Labeled With Number Two In A Red Circle Pointing To Target Section Id, In This Case, HomeScreenshot Of Developer Console With Red Arrow Labeled With Number One In Red Circle Pointing To Isintersecting Property With True Value And Red Arrow Labeled With Number Two In A Red Circle Pointing To Target Section Id, In This Case, Home

Based on this information, we can determine whether a section is intersecting or not by checking the value of the isIntersecting property. If its value is true, it indicates that a threshold has been crossed for that section.

According to the default configuration, if at least one pixel of the section is visible, the threshold is considered crossed.

Using the target property, as shown in the image above, we can access the id attribute of the active section:

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log(entry.target.getAttribute("id")); // home/ about/ project/ contact
    }
  });
});

The id of the section will match the href attribute of the corresponding menu link.

Referencing elements with the React useRef Hook

Instead of using methods like querySelectorAll() to obtain references to all the target <section> elements, we will utilize the useRef Hook provided by React.

Since we want to manage a list of refs, we will implement a ref callback function. This allows us to pass a function to the ref attribute, which we will attach to the target element, and maintain our own list.

To begin, we will pass a callback function to the ref attribute:

const Page = ({ section, refCallback }) => {
  // ...
  return (
    <section id={title} ref={refCallback}>
      {/* ... */}
    </section>
  );
};

export default Page;

In the parent component, we will create the callback function and ensure that we pass it down to the Page component:

function App() {
  // ...
  const refCallback = (element) => {
    if (element) {
      console.log(element);
    }
  };

  return (
    <>
      {/* ... */}
      <main>
        <div>
          {sections.map((section, index) => (
            <Page {...{ section, refCallback }} key={index} />
          ))}
        </div>
      </main>
    </>
  );
}

export default App;

If we take a look at the console, we have access to the target nodes:

Screenshot Of Developer Console Showing Four Target Nodes — Home, About, Project, ContactScreenshot Of Developer Console Showing Four Target Nodes — Home, About, Project, Contact

Remember, the querySelectorAll() method returns a collection of elements in an array-like NodeList, which we then iterate over. However, since these entries are not in a regular array that we can directly loop over, we will add them to a separate array to iterate over and manipulate them more easily.

To begin, we will create a reference and initialize it with an empty array. Then, in the refCallback function, we will update the array with the entries:

import { useEffect, useRef } from "react";
// ...

function App() {
  const sectionsRef = useRef([]);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      // ...
    });
    // const targetSections = document.querySelectorAll("section");
    sectionsRef.current.forEach((section) => {
      observer.observe(section);
    });
  }, []);

  const refCallback = (element) => {
    if (element) {
      sectionsRef.current.push(element);
    }
  };

  return (...);
}

export default App;

To avoid recreating the refCallback function on every render, we will wrap it in a useCallback Hook:

import { useEffect, useRef, useCallback } from "react";
// ...

function App() {
  // ...
  const refCallback = useCallback((element) => {
    if (element) {
      sectionsRef.current.push(element);
    }
  }, []);

  return (...);
}

export default App;

Now that we can observe the sections and obtain the current section ID, let’s explore how we can utilize that value to dynamically add an "active" class to the corresponding menu item.

Jump ahead in this section:

In the App component, where we render the header, we will create a state variable to manage the currently visible section of the page. We will initialize this state variable as the home page:

import { useState, useEffect, useRef } from "react";
// ...
const menus = ["home", "about", "project", "contact"];
// ...
function App() {
  const [visibleSection, setVisibleSection] = useState(menus[0]);
  // ...
  return (
    // ...
  );
}

export default App;

Next, we will check if the state value matches the current menu item on the li. If there is a match, we will add an "active" class to that menu item:

{menus.map((menu, index) => (
  <li
    key={index}
    className={visibleSection === menu ? "active" : ""}
  >
    <a href={`#${menu}`}>{menu}</a>
  </li>
))}

We will update the state whenever the status of the isIntersecting property of a section changes to true:

useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        setVisibleSection(entry.target.getAttribute("id"));
      }
    });
  });
  //...
}, []);

To visualize the active menu in action, we will add style rules for the "active" class:

/* styling active menu */
ul li::before {
  content: "";
  width: 100%;
  height: 0.25rem;
  background: #23d997;
  position: absolute;
  bottom: 0;
  left: 0;
  transform: scaleX(0);
  transition: transform 0.5s ease;
}

ul li.active::before {
  transform: scaleX(1);
}

Now, as we scroll through the page sections, the menu item will become active as soon as at least one pixel of the corresponding section becomes visible:

User Shown Scrolling Through Same Single Page Application With Header Now Dynamically Updating To Highlight Menu Items Depending On Section Shown. Header Does Not Change Color As In Final ProjectUser Shown Scrolling Through Same Single Page Application With Header Now Dynamically Updating To Highlight Menu Items Depending On Section Shown. Header Does Not Change Color As In Final Project

As we observed in the final demo, we want the background color of the header to change from dark to white when the isIntersecting property is true for sections other than the home section.

To do this, let’s obtain a reference to the header element:

function App() {
  const headerRef = useRef(null);
  // ...

  return (
    <>
      <header ref={headerRef}>
        {/* ... */}
      </header>
      {/* ... */}
    </>
  );
}

export default App;

Next, within the observer, we will define the logic that updates the class of the header element:

useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        // ...
        entry.target.id !== "home"
          ? headerRef.current.classList.add("bg-white")
          : headerRef.current.classList.remove("bg-white");
      }
    });
  });
  // ...
}, []);

In the above code, we checked if the intersecting section is not the home section, and if it isn’t, we added a class to the header element. The style rules for this class are declared below:

/* header background */
header.bg-white {
  background: white;
}

header.bg-white ul li a {
  color: black;
} 

Utilizing IntersectionObserver configuration options

We have the ability to control when we want the callback of the observer to be invoked by passing configuration options into the IntersectionObserver() constructor.

useEffect(() => {
  const options = {
    root: null,
    rootMargin: "0px",
    threshold: 0
  };

  const observer = new IntersectionObserver((entries) => {
    // ...
  }, options);

  // ...
}, []);

In the code provided above, we have applied the default values for the IntersectionObserver options. Let’s briefly discuss the properties:

  • The root property allows us to specify an ancestor element that serves as the viewport for checking the visibility of the target. If this property is not specified or is assigned a default value of null, it means we want to observe the intersection relative to the browser’s viewport
  • The rootMargin property allows us to define margins around the root element to expand or shrink it. This can affect the point at which the target element is considered inside the viewport, which can be useful when we need to load data before it becomes visible, such as when lazy-loading images or implementing infinite scrolling
    • Specifying a positive value will expand the root element and trigger the callback even when the observed element is still away from entering the viewport
    • Specifying a negative value will shrink the root element
  • The threshold property allows us to specify the percentage of the elements that must be visible for it to be considered intersecting. It accepts a number value between 0 and 1, and can also accept an array of numbers to create multiple trigger points
    • A default value of 0 means the element is considered intersecting as soon as a single pixel becomes visible
    • Setting a value of 1 means the entire element must be visible

In our project, we want to detect when visibility crosses the 50 percent mark. Therefore, we will update the configuration as follows:

useEffect(() => {
  const options = {
    threshold: 0.5
  };

  // ...
}, []);

See the source code and demo on CodeSandbox.

It may be beneficial to extract the functionality of the IntersectionObserver into a custom Hook to facilitate reusability. However, the react-intersection-observer package handles this seamlessly for us.

After thoroughly understanding how the native Intersection API works with React, using the react-intersection-observer package should be straightforward and effortless.

Jump ahead in this section:

Installing the react-intersection-observer package

First, we’ll add the package to our project:

npm install react-intersection-observer

The react-intersection-observer library provides both a Hook and a component API that we can utilize to monitor the state of our target element. In the next section, we will begin exploring the component API.

Using the <InView> component API

Similar to React’s render props pattern, we can pass a function to the <InView> component as the child prop. This function will return the JSX that the IntersectionObserver will monitor.

Below is a basic example of how to use the <InView> component API:

import { InView } from 'react-intersection-observer';

const Component = () => (
  <InView>
    {({ inView, ref, entry }) => (
      <div ref={ref}>
        <p>{`This paragraph is inside the viewport: ${inView}.`}</p>
      </div>
    )}
  </InView>
);

export default Component;

The function receives an object that contains the inView state, a ref, and the entry. The inView state returns a Boolean value based on the visibility of the target element. The function will be called with the new value whenever the state changes.

We can then assign the received ref to the element we want to monitor. Meanwhile, the entry object will provide access to all the details about the intersection state.

With this basic understanding, let’s apply the API in our React project.

In the src/App.js file of our starter project, we can import the component from the react-intersection-observer package:

import { InView } from "react-intersection-observer";

Next, we need to ensure that we return the target JSX from the render props that we will pass to the <InView> component. Since the target <section> element resides in a child component, we will return the component itself instead. Let’s locate the following code:

<div>
  {sections.map((section, index) => (
    <Page {...{ section }} key={index} />
  ))}
</div>

Then, update the code to the following:

<div>
  {sections.map((section, index) => (
    <InView key={index}>
      {({ ref: inViewRef, inView, entry }) => {
        return <Page {...{ section, inViewRef }} />;
      }}
    </InView>
  ))}
</div>

You may have noticed that we used an alias for the ref we passed to the child component. This is necessary because we can’t directly pass a named ref as props to a nested functional component unless we wrap the child component in a forwardRef() or use a custom prop, as we have done here.

Now, in the child component, we can assign the custom prop to the ref attribute of the target element:

const Page = ({ section, inViewRef }) => {
  // ...
  return (
    <section id={title} ref={inViewRef}>
      {/* ... */}
    </section>
  );
};

export default Page;

We can now define the logic that implements the dynamic header. Similar to what we did in the first project, we will obtain a reference to the header element and define a state for the component:

import { useState, useRef } from "react";
// ...
function App() {
  const [visibleSection, setVisibleSection] = useState(menus[0]);
  const headerRef = useRef();
  return (
    <>
      <header ref={headerRef}>
        <nav>
          <ul>
            {menus.map((menu, index) => (
              <li
                key={index}
                className={visibleSection === menu ? "active" : ""}
              >
                <a href={`#${menu}`}>{menu}</a>
              </li>
            ))}
          </ul>
        </nav>
      </header>
      <main>
        {/* ... */}
      </main>
    </>
  );
}

export default App;

After that, we’ll update the render props with the intersection logic:

<InView key={index}>
  {({ ref: inViewRef, inView, entry }) => {
    if (inView) {
      setVisibleSection(entry.target.getAttribute("id"));

      entry.target.id !== "home"
        ? headerRef.current.classList.add("bg-white")
        : headerRef.current.classList.remove("bg-white");
    }
    return <Page {...{ section, inViewRef }} />;
  }}
</InView>

Don’t forget to include the CSS rules:

/* styling active menu */
ul li::before {
  content: "";
  width: 100%;
  height: 0.25rem;
  background: #23d997;
  position: absolute;
  bottom: 0;
  left: 0;
  transform: scaleX(0);
  transition: transform 0.5s ease;
}

ul li.active::before {
  transform: scaleX(1);
}

/* header background */
header.bg-white {
  background: white;
}

header.bg-white ul li a {
  color: black;
}

Avoid updating the state of other components during render

Though the above code works, we’ll see the following React warning displayed in the console:

Warning: Cannot update a component (App) while rendering a different component (InView).

This warning occurs because the component is calling the setVisibleSection updater function of the parent component while it is still rendering. This creates a race condition.

To resolve this issue, we can either place the state updater logic in a useEffect Hook or ensure that we update the state when an event occurs.

Fortunately, the InView API provides an onChange prop option that is invoked whenever the inView state changes. Inside its handler function, we can access the inView status and the entry object, which we can use to define the logic that implements the dynamic header.

The updated code now appears as follows:

function App() {
  // ...
  const setInView = (inView, entry) => {
    if (inView) {
      setVisibleSection(entry.target.getAttribute("id"));

      entry.target.id !== "home"
        ? headerRef.current.classList.add("bg-white")
        : headerRef.current.classList.remove("bg-white");
    }
  };
  return (
    <>
      {/* ... */}
      <main>
        <div>
          {sections.map((section, index) => (
            <InView onChange={setInView} key={index}>
              {({ ref: inViewRef }) => {
                return <Page {...{ section, inViewRef }} />;
              }}
            </InView>
          ))}
        </div>
      </main>
    </>
  );
}

export default App;

Passing in react-intersection-observer options

We have the ability to pass IntersectionObserver options as props to the <InView /> component to control when the observer should be invoked.

Just like we did with the native API using the threshold, we can update the props of the <InView> component to include a threshold. Here is an example of how it can be done:

<InView onChange={setInView} threshold={0.5} key={index}>
  {/* ... */}
</InView>

See the source code and demo on CodeSandbox.

Using the useInView Hook

Implementing the useInView Hook follows a similar approach to monitor the state of our target element. This Hook accepts an optional configuration and returns the inView state, the ref, and the current entry.

Below is a basic example of how to implement the useInView Hook:

import { useInView } from "react-intersection-observer";

const Component = () => {
  const { ref, inView, entry } = useInView({
    threshold: 0.5
  });

  return (
    <div ref={ref}>
      <p>{`This paragraph is inside the viewport: ${inView}.`}</p>
    </div>
  );
};

export default Component;

Let’s apply the Hook in our React project.

Once again, using the starter project, we will apply the useInView Hook in the components/Page.js file — the same file that renders the target section element.

In this file, let’s import the useInView Hook and assign the ref to the section element that we want to monitor:

import { useInView } from "react-intersection-observer";

const Page = ({ section }) => {
  // ...
  const { ref, inView, entry } = useInView({
    threshold: 0.5
  });

  return (
    <section id={title} ref={ref}>
      {/* ... */}
    </section>
  );
};

export default Page;

In the same file, we will add the logic that implements the dynamic header within a useEffect Hook. This will result in the following code:

import { useEffect } from "react";
// ...
const Page = ({ section }) => {
  // ...
  useEffect(() => {
    if (inView) {
      setVisibleSection(entry.target.id);

      entry.target.id !== "home"
        ? headerRef.current.classList.add("bg-white")
        : headerRef.current.classList.remove("bg-white");
    }
  }, [inView, entry, setVisibleSection, headerRef]);

  return (
    // ...
  );
};

export default Page;

Alternatively, we can add the logic to the onChange option of the useInView Hook instead of using a separate useEffect Hook. This would result in the following code:

const { ref } = useInView({
  threshold: 0.5,
  onChange: (inView, entry) => {
    if (inView) {
      setVisibleSection(entry.target.id);

      entry.target.id !== "home"
        ? headerRef.current.classList.add("bg-white")
        : headerRef.current.classList.remove("bg-white");
    }
  }
});

Since we utilized the setVisibleSection function and headerRef variable in the logic, we will pass these variables from the parent component like so:

import { useState, useRef } from "react";
// ...
function App() {

  const [visibleSection, setVisibleSection] = useState(menus[0]);
  const headerRef = useRef();

  return (
    <>
      <header ref={headerRef}>
        {/* ... */}
            {menus.map((menu, index) => (
              <li key={index} className={visibleSection === menu ? "active" : ""}>
                {/* ... */}
              </li>
            ))}
          {/* ... */}
          {sections.map((section, index) => (
            <Page {...{ section, setVisibleSection, headerRef }} key={index} />
          ))}
        </div>
      </main>
    </>
  );
}

export default App;

Then, within the child component, we need to access the variables as props, like this:

const Page = ({ section, setVisibleSection, headerRef }) => {
  // ...
  return (
    // ...
  );
};

export default Page;

Finally, we’ll include the CSS rules for the added classes:

/* styling active menu */
ul li::before {
  content: "";
  width: 100%;
  height: 0.25rem;
  background: #23d997;
  position: absolute;
  bottom: 0;
  left: 0;
  transform: scaleX(0);
  transition: transform 0.5s ease;
}

ul li.active::before {
  transform: scaleX(1);
}

/* header background */
header.bg-white {
  background: white;
}

header.bg-white ul li a {
  color: black;
}

See the source code and demo on CodeSandbox.

How to disconnect the IntersectionObserver or stop observing an element

With the way we’re currently implementing the IntersectionObserver, the target sections are being continuously monitored as they enter and exit the viewport. While this behavior is suitable for our current use case, there may be situations where we do not want continuous observation.

For example, in certain scenarios, we may want to stop observing an element once it becomes visible. This behavior is commonly expected when lazy-loading images, where we want them to stay visible once they enter the viewport.

The unobserve() method

Let’s explore how we can stop observing a target element after it enters the viewport. Using the native API, we can call the unobserve method on the observer itself, passing the target element as an argument:

const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // ...
      observer.unobserve(entry.target);
    }
  });
}, options); 

It’s worth noting that the IntersectionObserver() constructor provides the observer instance as the second argument in the callback function.

The disconnect() method

It is important to disconnect the IntersectionObserver when it is no longer needed. To do this, we will invoke the disconnect() method on the observer inside a cleanup function within the useEffect Hook:

useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    // ...
  }, options);
  // ...
  // To disable the entire IntersectionObserver
  return () => {
    observer.disconnect();
  };
}, []);

By including the cleanup code, the IntersectionObserver will be disconnected when the component unmounts, preventing any potential memory leaks.

If you utilize the react-intersection-observer library, the observer instances will be disconnected automatically when they are no longer needed. This eliminates the need for manual disconnection, making the process more efficient in your React applications.

The triggerOnce option

With the react-intersection-observer package, we can configure the observer to trigger only once after the target element enters the viewport by using the triggerOnce Boolean option.

Implementing the <InView> component will look like this:

<InView
  // ...
  triggerOnce={true}
> 
  {/* ... */}
</InView>

Meanwhile, using the useInView Hook will look like so:

const { ref, inView, entry} = useInView({
  // ...
  triggerOnce: true,
}); 

Remember, thanks to the library we’re using, the observer instances will automatically disconnect when they are no longer needed. In this case, whether we trigger the IntersectionObserver once or not, it will disconnect after the component unmounts.

Conclusion

In this article, we delved into the impressive capabilities of the react-intersection-observer package, using it to create a dynamic header within a React project. Diving into the inner workings of the native Intersection Observer API gave us a deep understanding of its functionality and versatility.

Another project that can be implemented using a procedure similar to the one we learned in this article is the dynamic table of contents. This feature is often used in blog posts to enhance the navigation experience. With the insights gained from this article, developing such a project should be straightforward.

If you found this lesson helpful, please consider sharing it with others. If you have any questions or contributions, feel free to share your thoughts in the comments section.

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.
  3. $ npm i --save logrocket 

    import LogRocket from 'logrocket';
    LogRocket.init('app/id');

    Add to your HTML:

    <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
    <script>window.LogRocket && window.LogRocket.init('app/id');</script>

  4. (Optional) Install plugins for deeper integrations with your stack:
    • Redux middleware
    • ngrx middleware
    • Vuex plugin

Get started now

Coastal Media Brand

© 2024 Coastal Media Brand. All rights Reserved.