Table of contents
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.