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 thehandleScroll
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
-
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.
-
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 tonull
means the observer uses the browser viewport as the root.rootMargin
: The margin around the root. In this case, we offset the bottom by70%
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 ofIntersectionObserverEntry
objects, each representing a section being observed.- Filter Visible Sections: We filter the entries to get only those where
entry.isIntersecting
istrue
. - Determine Most Visible Section:
- We use
reduce
to find the section with the highestintersectionRatio
, meaning it's the most visible in the viewport.
- We use
return
theid
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:
- We pass in the
observerCallback
function andobserverOptions
object. - The observer will now watch for intersection changes based on these parameters.
- We pass in the
-
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 callobserver.observe(section)
on each one. - This sets up the observer to monitor each section for intersection changes.
- We loop through the
-
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.
- Calling
-
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.
- Client-Side Check: We ensure the code runs only on the client side by checking
- 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 accessactiveSection
. - Render Menu Items: Loop through
menuItems
and render each as a list item. - Conditional Class: Apply the
'active'
class if the menu item'sanchor
matchesactiveSection
.
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.