Enhance Your Next.js Application with Intersection Observer API

Track when an element or section is within the users viewport for active section tracking

January 20, 2025

By Craig Hicks

Introduction to the Intersection Observer API

Modern web development will sometimes require you to provide UI updates or trigger actions once an element is in the users viewport. For instance, a sticky menu with page anchors needs to highlight the active section based on what the user is currently looking at. One way to achieve this is by implementing an active section tracker using the Intersection Observer API.

How Was This Done Previously?

Before the Intersection Observer API, developers commonly used the following methods to detect element visibility and track active sections:

  • Scroll Event Listeners: Attaching event listeners to the scroll event on the window or specific elements.

      function handleScroll() {
        const sections = document.querySelectorAll('section');
        sections.forEach((section) => {
          const rect = section.getBoundingClientRect();
          if (rect.top >= 0 && rect.top <= window.innerHeight / 2) {
            // Update active section state
          }
        });
      }
    
      window.addEventListener('scroll', handleScroll);
    
  • Throttling and Debouncing: Since the scroll event fires rapidly, developers implemented throttling or debouncing techniques to limit the frequency of the handleScroll function execution. There were libraries to help with this but it essentially did something like this:

      function throttle(fn, wait) {
        let time = Date.now();
        return function () {
          if (time + wait - Date.now() < 0) {
            fn();
            time = Date.now();
          }
        };
      }
    
      window.addEventListener('scroll', throttle(handleScroll, 100));
    

Drawbacks of Previous Methods:

  • Performance Issues: Frequent execution of the scroll event handler can lead to performance bottlenecks, especially on pages with complex layouts or on devices with limited resources.
  • Manual Calculations: Developers had to manually calculate element positions and visibility, increasing code complexity and the risk of errors.
  • Inefficient Repaints and Reflows: Accessing layout properties like getBoundingClientRect() can trigger reflows and repaints, which are expensive operations that degrade performance.

Benefits of the Intersection Observer

  • Asynchronous Observation: Intersection Observer runs asynchronously, reducing the impact on the main thread and improving performance.
  • No Manual Calculations: The API provides visibility information without the need to calculate element positions manually.
  • Optimized Callbacks: Browsers optimize Intersection Observer callbacks to fire only when necessary, reducing unnecessary computations.

  • Simplified Codebase: Cleaner and more maintainable code compared to handling scroll events and manual calculations.

Setting Up the Intersection Observer in Next.js

  1. Select Sections to Observe

    First, we need to identify the sections in our application that we want to observe. Typically, these are the main content sections that correspond to navigation items.

     // Select all sections with an id attribute
     const sections = document.querySelectorAll('section[id]');
    

    Explanation:

    • Whether these elements are predefined on a page or generated using code, we need the elements we want to observe.
  2. Define Observer Options

    Next, we'll define the options for our IntersectionObserver. These options determine how the observer behaves.

     const observerOptions = {
       root: null, // Use the viewport as the root
       rootMargin: '0px 0px -70% 0px', // Adjust the root margin to trigger earlier
       threshold: [0, 0.25, 0.5, 0.75, 1], // Set thresholds at which the callback should be invoked
     };
    

    Explanation:

    • root: Setting it to null means the observer uses the browser viewport as the root.
    • rootMargin: The margin around the root. In this case, we offset the bottom by 70% to trigger the observer when the section is within the top 30% of the viewport.
    • threshold: An array of intersection ratios. The observer will invoke the callback when the visibility of the target element crosses these thresholds.
  3. Create the Observer Callback

    The observer callback function handles the entries observed by the IntersectionObserver.

     const observerCallback = (entries) => {
       const visibleSections = entries.filter((entry) => entry.isIntersecting);
    
       if (visibleSections.length > 0) {
         const mostVisibleSection = visibleSections.reduce((prev, current) =>
           prev.intersectionRatio > current.intersectionRatio ? prev : current
         );
         // return the active section
         return mostVisibleSection.target.id;
       }
     };
    

    Explanation:

    • entries: An array of IntersectionObserverEntry objects, each representing a section being observed.
    • Filter Visible Sections: We filter the entries to get only those where entry.isIntersecting is true.
    • Determine Most Visible Section:
      • We use reduce to find the section with the highest intersectionRatio, meaning it's the most visible in the viewport.
    • return the id of the most visible section.
  4. Instantiate the Intersection Observer

    Now, we can create a new IntersectionObserver instance with the callback and options defined.

     const observer = new IntersectionObserver(observerCallback, observerOptions);
    

    Explanation:

    • We pass in the observerCallback function and observerOptions object.
    • The observer will now watch for intersection changes based on these parameters.
  5. Observe Each Section

    We instruct the observer to start observing each section.

     sections.forEach((section) => observer.observe(section));
    

    Explanation:

    • We loop through the sections NodeList and call observer.observe(section) on each one.
    • This sets up the observer to monitor each section for intersection changes.
  6. Cleanup the Observer

    It's important to disconnect the observer when it's no longer needed to prevent memory leaks.

     // Disconnect the observer when done
     observer.disconnect();
    

    Explanation:

    • Calling observer.disconnect() stops the observer from watching any targets.
  7. Encapsulate into a Custom Hook

    Now that we've set up the observer, let's encapsulate this logic into a reusable custom hook called useActiveSection.

     // hooks/useActiveSection.js
     import { useState, useEffect } from 'react';
    
     export function useActiveSection() {
       const [activeSection, setActiveSection] = useState('');
    
       useEffect(() => {
         if (typeof window === 'undefined') return; // Ensure this runs only on the client side
    
         const sections = document.querySelectorAll('section[id]');
    
         const observerOptions = {
           root: null,
           rootMargin: '0px 0px -70% 0px',
           threshold: [0, 0.25, 0.5, 0.75, 1],
         };
    
         const observerCallback = (entries) => {
           const visibleSections = entries.filter((entry) => entry.isIntersecting);
           if (visibleSections.length > 0) {
             const mostVisibleSection = visibleSections.reduce((prev, current) =>
               prev.intersectionRatio > current.intersectionRatio ? prev : current
             );
             setActiveSection(mostVisibleSection.target.id);
           }
         };
    
         const observer = new IntersectionObserver(observerCallback, observerOptions);
    
         sections.forEach((section) => observer.observe(section));
    
         return () => observer.disconnect();
       }, []);
    
       return activeSection;
     }
    

    Explanation:

    • useState Hook: activeSection holds the ID of the currently active section.
    • useEffect Hook: Sets up the observer when the component mounts and cleans up when it unmounts.
      • Client-Side Check: We ensure the code runs only on the client side by checking typeof window !== 'undefined'.
      • Observer Setup: We include the same observer setup code we discussed earlier.
      • Cleanup Function: Disconnects the observer when the component unmounts.
    • Update callback to setActiveSection state instead of returning it.
    • Return Value: The hook returns the activeSection, which can be used by components to update the UI accordingly.

Integrating with Sitecore JSS Components

With the useActiveSection hook ready, let's see how to integrate it into your Sitecore JSS components.

Creating a Navigation Component with Highlighting

// components/AnchorNavbar.js
import Link from 'next/link';
import { useActiveSection } from '../hooks/useActiveSection';

export default function Navbar({ menuItems }) {
  const activeSection = useActiveSection();

  return (
    <nav>
      <ul>
        {menuItems.map((item) => (
          <li key={item.id} className={activeSection === item.anchor ? 'active' : ''}>
            <Link href={`#${item.anchor}`}>
              <a>{item.label}</a>
            </Link>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Explanation:

  • Import the Hook: We import useActiveSection to get the current active section.
  • Use the Hook: Call useActiveSection() inside the component to access activeSection.
  • Render Menu Items: Loop through menuItems and render each as a list item.
  • Conditional Class: Apply the 'active' class if the menu item's anchor matches activeSection.

Building a Solid Foundation for Next.js

By starting with selecting the sections to observe and then setting up the IntersectionObserver, we built a solid foundation for tracking active sections in your Next.js application. Encapsulating this logic into a custom hook makes it reusable and maintainable.

Further Reading

MDN Web Docs: Intersection Observer API

A photo of Craig Hicks, an employee at Fishtank

Craig Hicks

Front End Developer

Craig Hicks (or ‘chicks’ for short) is a seasoned developer whose expertise spans web development, digital media, project management, and leadership. Throughout his career he has evolved from hands-on coding roles to strategic management positions, and aims to apply his experience into his passion for problem-solving and development. His love for continuous learning and diving into new challenges applies to both his professional life and personal pursuits. Outside of work, he enjoys music, movies, and sports with friends and family.