How to Highlight Active Navigation on Scroll in React

I was working on a project that required me to highlight the active menu as a user scrolls through different sections.

I had previously implemented this feature using plain JavaScript. To achieve this, I simply used querySelector to get the element's class name, then checked the window.pageYOffset value to determine whether to add or remove the active class from the element's classList. Here's the code I used:

const nav = document.querySelector(".navbar");

window.addEventListener("scroll", () => {
  if (window.pageYOffset > 50) {
    nav.classList.add("navscroll");
  } else {
    nav.classList.remove("navscroll");
  }
});

In the code above, I defined an active class named navscroll in the style.css file. This solution worked well for my use case and was relatively easy to implement.

There are different ways this can be achieved in React, one of which is using the Intersection Observer API.

Intersection Observer API

The Intersection Observer API in React offers several benefits over traditional methods. For instance, it provides an efficient way to determine if an element is visible in the viewport or not, without needing to rely on scroll events or polling. This can help to reduce the amount of work that the browser needs to do, resulting in better performance and smoother user experiences.

To use the Intersection Observer API in React, you create a new instance of the IntersectionObserver class and pass it a callback function invoked when the observed elements intersect with the viewport. This callback function is then used to update the active section state.

Let's use the Intersection Observer API to add an "active" class to a navigation menu item when it comes into view:

import React, { useState, useEffect, useRef } from 'react';

const Menu = () => {
  const [activeSection, setActiveSection] = useState(null);
  const observer = useRef(null);

  useEffect(() => {
//create new instance and pass a callback function
    observer.current = new IntersectionObserver((entries) => {
      const visibleSection = entries.find((entry) => entry.isIntersecting)?.target;
//Update state with the visible section ID
      if (visibleSection) {
        setActiveSection(visibleSection.id);
      }
    });

//Get custom attribute data-section from all sections
    const sections = document.querySelectorAll('[data-section]');

    sections.forEach((section) => {
      observer.current.observe(section);
    });
//Cleanup function to remove observer
    return () => {
      sections.forEach((section) => {
        observer.current.unobserve(section);
      });
    };
  }, []);
  const activeStyle = {
    fontWeight: 'bold',
    color: 'red',
    textDecoration: 'underline',
  };

  return (
    <>
     <nav style={{ position: 'fixed', top: 0 }}>
        <ul style={{ listStyle: 'none', display: 'flex', 
          margin: 0,     padding: 0 }}>
          <li className={activeSection === 'section1' ? 'active' : ''}  style={{ margin: '0 10px' }}>
            <a href="#section1" style={activeSection === 
       'section1' ? activeStyle : {}}>Section 1</a>
          </li>



          <li className={activeSection === 'section2' ? 'active' : ''} style={{ margin: '0 10px' }}>
            <a href="#section2" style={activeSection === 
            'section2' ? activeStyle : {}}>Section 2</a>
          </li>
          <li className={activeSection === 'section3' ? 'active' : ''} style={{ margin: '0 10px' }}>
            <a href="#section3" style={activeSection === 
         'section3' ? activeStyle : {}}>Section 3</a>
          </li>
        </ul>
      </nav>
      <div style={{marginTop:"40px"}}>
    <div data-section id="section1">
    {/* Enter section text here */}
    </div>
    <div data-section id="section2">
    {/* Enter section text here */}
    </div>
    <div data-section id="section3">
    {/* Enter section text here */}
    </div>
    </div>
    </>
  );
};

export default Menu

In the above code, we create a new IntersectionObserver instance and attach it to each section of our page. When the observed sections intersect with the viewport, the IntersectionObserver callback function sets the activeSection state to the id of the visible section. We then use the activeSection state to conditionally apply an active class to the corresponding menu item.

View this sample implementation on sandbox

Scroll Event Listener

An alternative approach to implementing this functionality is by using a scroll event listener. This method is similar to what was used in the first example with plain JavaScript.

In this process, a custom attribute is added to the sections being observed, then we reference the sections and retrieve their current vertical scroll position on the page and use it to set the active section. When the active section matches the menu link, we then mark the menu as active.

import React, { useState, useEffect, useRef } from 'react';

const Menu = () => {
  const [activeSection, setActiveSection] = useState(null);
  const sections = useRef([]);

  const handleScroll = () => {
    const pageYOffset = window.pageYOffset;
    let newActiveSection = null;

    sections.current.forEach((section) => {
      const sectionOffsetTop = section.offsetTop;
      const sectionHeight = section.offsetHeight;

      if (pageYOffset >= sectionOffsetTop && pageYOffset 
      < sectionOffsetTop + sectionHeight) {
        newActiveSection = section.id;
      }
    });

    setActiveSection(newActiveSection);
  };

  useEffect(() => {
    sections.current = document.querySelectorAll('[data-section]');
    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  const activeStyle = {
    fontWeight: 'bold',
    color: 'red',
    textDecoration: 'underline',
  };

  return (
    <>
      <nav style={{ position: 'fixed', top: 0 }}>
        <ul style={{ listStyle: 'none', display: 'flex', margin: 0, padding: 0 }}>
          <li className={activeSection === 'section1' ? 'active' : ''} style={{ margin: '0 10px' }}>
            <a href="#section1" style={activeSection === 
        'section1' ? activeStyle : {}}>
              Section 1
            </a>
          </li>
          <li className={activeSection === 'section2' ? 'active' : ''} style={{ margin: '0 10px' }}>
            <a href="#section2" style={activeSection === 
        'section2' ? activeStyle : {}}>
              Section 2
            </a>
          </li>
          <li className={activeSection === 'section3' ? 'active' : ''} style={{ margin: '0 10px' }}>
            <a href="#section3" style={activeSection === 
          'section3' ? activeStyle : {}}>
              Section 3
            </a>
          </li>
        </ul>
      </nav>
      <div style={{ marginTop: '40px' }}>
        <div data-section id="section1">
          {/* Enter section text here */}
        </div>
        <div data-section id="section2">
          {/* Enter section text here */}
        </div>
        <div data-section id="section3">
          {/* Enter section text here */}
        </div>
      </div>
    </>
  );
};

export default Menu;

In this implementation, we define a handleScroll function that is called every time the user scrolls the page. This function uses window.pageYOffset to get the page's current scroll position and then checks each section's position and height to determine which section is currently in view. The sections ref is used to keep track of the section elements.

We then use useEffect to add an event listener for the scroll event and remove it when the component is unmounted. Finally, we update the active section state and render the active style based on the current active section.

View sample implementation on sandbox

Summary

Event Listeners provide more flexibility and control over how and when elements are detected. They can be used to detect a variety of events, not just intersection with the viewport. However, they can be less performant if not properly optimized, as they require continuous checks on the DOM.

The Intersection Observer API is designed specifically for detecting when an element enters or leaves the viewport. It is efficient and does not require continuous checks as event listeners do, which can improve performance. However, it may not always be supported by older browsers, and there can be situations where it skips over some elements.

Ultimately, it's up to you to decide which method to use based on your specific needs and the trade-offs involved.

Conclusion

I used the Event Listener for my project as the codebase was bulky and involved a lot of components. Also, the Intersection Observer API kept skipping over a lot of my navigation menu(haven't found a fix for this yet), which prompted me to choose the Event Listener. With the Event Listener, I had more control over how the event was being handled and the flexibility to adapt it to my needs

I hope this helps you in deciding what method to use.

Drop some comments if you found this interesting or have an alternative solution.