Building a Scroll Spy Hook with React and IntersectionObserver
Single-page portfolio sites need a way to highlight the current section in navigation as users scroll. The browser's IntersectionObserver API is perfect for this — but wiring it up correctly with React hooks takes some care. Here's how I built the useScrollSpy hook for this site.
The Problem
I have seven sections on a single page: Home, About, Experience, Tech Stack, Education, Recognition, and Contact. A fixed sidebar nav needs to highlight whichever section the user is currently viewing. Sounds simple, but there are edge cases:
- Multiple sections visible at once — tall screens can show two or three sections simultaneously
- Short sections — Education and Awards might be only 200px tall and scroll past quickly
- Bottom of page — the last section (Contact) may never fill the viewport
Starting Simple: IntersectionObserver + useState
The initial approach tracks which sections are intersecting and picks one:
import { useEffect, useState } from "react";
export function useScrollSpy(sectionIds: string[]) {
const [activeId, setActiveId] = useState("");
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: "-80px 0px -50% 0px", threshold: 0 }
);
for (const id of sectionIds) {
const el = document.getElementById(id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [sectionIds]);
return activeId;
}This works for tall sections but fails for short ones. The rootMargin crops the detection zone to the top half of the viewport, and a short section can scroll through without ever triggering.
The Fix: Track All Visible Sections with useRef
Instead of reacting to individual intersection events, we maintain a Set of all currently visible section IDs using useRef — this avoids re-renders when the set changes — and pick the best one:
import { useEffect, useState, useRef, useCallback } from "react";
export function useScrollSpy(sectionIds: string[]) {
const [activeId, setActiveId] = useState("");
const visibleRef = useRef(new Set<string>());
const pickActive = useCallback(() => {
const visible = visibleRef.current;
// Bottom of page: activate the last visible section
const atBottom =
window.innerHeight + window.scrollY >=
document.body.scrollHeight - 50;
if (atBottom && visible.size > 0) {
const last = [...sectionIds]
.reverse()
.find((id) => visible.has(id));
if (last) {
setActiveId(last);
return;
}
}
// Otherwise pick the section closest to the top
let best = "";
let bestDist = Infinity;
for (const id of sectionIds) {
if (!visible.has(id)) continue;
const el = document.getElementById(id);
if (!el) continue;
const dist = Math.abs(el.getBoundingClientRect().top - 80);
if (dist < bestDist) {
bestDist = dist;
best = id;
}
}
if (best) setActiveId(best);
}, [sectionIds]);
useEffect(() => {
const visible = visibleRef.current;
visible.clear();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visible.add(entry.target.id);
} else {
visible.delete(entry.target.id);
}
}
pickActive();
},
{ rootMargin: "-60px 0px 0px 0px", threshold: 0 }
);
for (const id of sectionIds) {
const el = document.getElementById(id);
if (el) observer.observe(el);
}
window.addEventListener("scroll", pickActive, { passive: true });
return () => {
observer.disconnect();
window.removeEventListener("scroll", pickActive);
};
}, [sectionIds, pickActive]);
return activeId;
}Why Each Hook Matters
useRef for the visible set — we need mutable state that persists across renders without triggering re-renders. The IntersectionObserver callback fires frequently, and we don't want a render for every intersection change. We only render when the active section changes.
useCallback for pickActive — this function is used both as the IntersectionObserver callback and as a scroll event listener. Wrapping it in useCallback ensures the scroll listener cleanup works correctly and the function identity is stable across renders.
useState only for activeId — this is the one piece of state the consumer actually reads, so it needs to trigger a render.
useEffect handles observer setup and teardown. The dependency array includes sectionIds and pickActive. When either changes, the observer reconnects.
Using the Hook
The consuming component is straightforward:
const sections = [
{ id: "hero", label: "Home" },
{ id: "about", label: "About" },
{ id: "experience", label: "Experience" },
// ...
];
const SECTION_IDS = sections.map((s) => s.id);
export function SectionNav() {
const activeId = useScrollSpy(SECTION_IDS);
return (
<nav>
{sections.map(({ id, label }) => (
<a
key={id}
href={`#${id}`}
className={activeId === id ? "active" : ""}
>
{label}
</a>
))}
</nav>
);
}Key Takeaways
useReffor mutable data that shouldn't trigger renders — the visible set changes constantly, but only the derivedactiveIdmatters to the UI- Combine IntersectionObserver with scroll events for edge cases like bottom-of-page detection
rootMarginoffsets are essential when you have a sticky header — without-60pxtop margin, sections behind the header would register as visibleuseCallbackstabilizes function identity so event listener cleanup works correctly inuseEffect