Enhance Your Next.js Application with Intersection Observer API
Track when an element or section is within the users viewport for active section tracking
Start typing to search...
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.
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:
scroll event handler can lead to performance bottlenecks, especially on pages with complex layouts or on devices with limited resources.getBoundingClientRect() can trigger reflows and repaints, which are expensive operations that degrade performance.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.
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:
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.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.entry.isIntersecting is true.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.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:
observerCallback function and observerOptions object.Observe Each Section
We instruct the observer to start observing each section.
sections.forEach((section) => observer.observe(section));
Explanation:
sections NodeList and call observer.observe(section) on each one.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:
observer.disconnect() stops the observer from watching any targets.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.
typeof window !== 'undefined'.setActiveSection state instead of returning it.activeSection, which can be used by components to update the UI accordingly.With the useActiveSection hook ready, let's see how to integrate it into your Sitecore JSS components.
// 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:
useActiveSection to get the current active section.useActiveSection() inside the component to access activeSection.menuItems and render each as a list item.'active' class if the menu item's anchor matches activeSection.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.