Back to blog
·4 min read

Building a Scroll Spy Hook with React and IntersectionObserver

ReactHooksTypeScriptIntersectionObserver

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

  • useRef for mutable data that shouldn't trigger renders — the visible set changes constantly, but only the derived activeId matters to the UI
  • Combine IntersectionObserver with scroll events for edge cases like bottom-of-page detection
  • rootMargin offsets are essential when you have a sticky header — without -60px top margin, sections behind the header would register as visible
  • useCallback stabilizes function identity so event listener cleanup works correctly in useEffect