Highlighting the right table-of-contents link as a reader scrolls through a long article — commonly called scroll-spy navigation — looks like a simple problem until you have more than one heading capable of being "the" current section at once. This guide builds a scroll-spy implementation on IntersectionObserver using a centered rootMargin band, covers how to break ties between simultaneously-visible sections, and wires up aria-current correctly — a companion technique to the single-boundary approach in building a sticky header with IntersectionObserver.
Problem / Scenario Context
The naive approach measures every section's getBoundingClientRect().top inside a scroll listener and picks whichever section's top is closest to zero. This forces a synchronous layout read on every scroll event for every section on the page — expensive on long documents with dozens of headings — and still requires you to solve the exact same "which section counts as current" ambiguity that a smarter geometry-based approach solves for free.
The core insight for scroll-spy is that a section should only count as "active" while it occupies a specific band of the viewport — typically near vertical center, where a reader's eyes are assumed to rest — not merely while any part of it is visible. IntersectionObserver's rootMargin and threshold options can express this band directly, without a single manual getBoundingClientRect() call. This is one variant of the broader scroll-driven effects and sticky headers toolkit built on the same sentinel principle.
Mechanics Explanation
rootMargin shrinks or expands the effective root before the intersection test runs. A value like -40% 0px -40% 0px pulls the top and bottom edges of the root inward by 40% of the viewport height each, leaving a horizontal band roughly centered on the viewport — only sections whose bounding box overlaps that band report isIntersecting: true.
In this configuration, Section 2 overlaps the band while Sections 1, 3, and 4 do not, so only Section 2's entry reports isIntersecting: true in that callback. If the band is wide enough for two short sections to both overlap it, the callback receives both entries — resolving that tie is the next problem to solve.
Building It Step by Step
Step 1 — Structure sections and nav links with matching ids
<nav aria-label="Table of contents">
<a href="#intro" class="toc-link">Introduction</a>
<a href="#setup" class="toc-link">Setup</a>
<a href="#config" class="toc-link">Configuration</a>
</nav>
<article>
<section id="intro">…</section>
<section id="setup">…</section>
<section id="config">…</section>
</article>
Step 2 — Create one observer with a centered band
interface ScrollSpyOptions {
bandPercent?: number; // fraction of viewport height excluded top and bottom
}
function initScrollSpy(
sectionSelector: string,
navLinkSelector: string,
options: ScrollSpyOptions = {}
): () => void {
const { bandPercent = 40 } = options;
const sections = Array.from(document.querySelectorAll<HTMLElement>(sectionSelector));
const links = new Map<string, HTMLAnchorElement>(
Array.from(document.querySelectorAll<HTMLAnchorElement>(navLinkSelector))
.map((link) => [link.getAttribute('href')!.slice(1), link])
);
let activeId: string | null = null;
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
const visible = entries.filter((e) => e.isIntersecting);
if (visible.length === 0) return;
// Tie-break: highest intersectionRatio wins
const winner = visible.reduce((best, entry) =>
entry.intersectionRatio > best.intersectionRatio ? entry : best
);
const id = winner.target.id;
if (id === activeId) return;
if (activeId) links.get(activeId)?.removeAttribute('aria-current');
links.get(id)?.setAttribute('aria-current', 'true');
activeId = id;
},
{ rootMargin: `-${bandPercent}% 0px -${bandPercent}% 0px`, threshold: 0 }
);
sections.forEach((section) => observer.observe(section));
return () => observer.disconnect();
}
// Plain JS equivalent (no types)
function initScrollSpy(sectionSelector, navLinkSelector, options = {}) {
const bandPercent = options.bandPercent ?? 40;
const sections = Array.from(document.querySelectorAll(sectionSelector));
const links = new Map(
Array.from(document.querySelectorAll(navLinkSelector)).map((l) => [l.getAttribute('href').slice(1), l])
);
let activeId = null;
const observer = new IntersectionObserver((entries) => {
const visible = entries.filter((e) => e.isIntersecting);
if (!visible.length) return;
const winner = visible.reduce((best, e) => (e.intersectionRatio > best.intersectionRatio ? e : best));
const id = winner.target.id;
if (id === activeId) return;
if (activeId) links.get(activeId)?.removeAttribute('aria-current');
links.get(id)?.setAttribute('aria-current', 'true');
activeId = id;
}, { rootMargin: `-${bandPercent}% 0px -${bandPercent}% 0px`, threshold: 0 });
sections.forEach((s) => observer.observe(s));
return () => observer.disconnect();
}
Step 3 — Handle the multi-intersecting-section tie case
The reducer above already picks the highest intersectionRatio, which favors whichever section fills more of the narrow band. For content where sections are frequently short enough to tie exactly, add a secondary comparison on boundingClientRect.top distance from the band's vertical center, so the tie-break is deterministic even when ratios match to several decimal places.
| Tie-break signal | Handles unequal section heights | Deterministic on exact ties | Cost |
|---|---|---|---|
| Array order (first entry) | No | No — order is not guaranteed | Free, but unreliable |
Highest intersectionRatio |
Yes | Mostly — rare exact ties possible | Free (already on the entry) |
boundingClientRect.top distance from band center |
Yes | Yes | Free (already on the entry) |
Step 4 — Style the active state
.toc-link {
color: var(--text-muted, #6b7280);
text-decoration: none;
}
.toc-link[aria-current='true'] {
color: var(--accent-color, #1d4ed8);
font-weight: 600;
border-inline-start: 2px solid currentColor;
padding-inline-start: 0.5rem;
}
Style the active state from the [aria-current='true'] attribute selector directly rather than a parallel .is-active class — this keeps the visual state and the accessible state impossible to desynchronize, since there is only one source of truth.
Step 5 — Accessibility: aria-current over class-only styling
Screen readers do not announce arbitrary CSS classes, so if the only signal of the active section is a .active class with no attribute change, assistive technology users navigating the table of contents have no way to perceive which link corresponds to their current reading position. Setting aria-current="true" (or aria-current="location" for a landmark-style table of contents) on the matching link, and removing it from the previous one in the same update, exposes the same state to the accessibility tree that sighted users perceive visually.
Step 6 — Teardown
Unlike a one-shot reveal animation, scroll-spy needs continuous observation for the page's entire lifetime — never call unobserve() on a section just because it has been visited, since the reader may scroll back to it. Only tear the whole thing down on full unmount:
useEffect(() => {
const dispose = initScrollSpy('article section[id]', '.toc-link');
return () => dispose();
}, []);
Edge Cases & Gotchas
Very short sections that fit entirely inside the band. A short FAQ-style section may be small enough that it, and part of a longer neighboring section, are both inside the centered band simultaneously. The intersectionRatio tie-break generally favors whichever section fills more of the band's height, but for near-equal cases add the boundingClientRect.top secondary comparison described above.
Band width vs. section count. A 40% band works well for typical article sections a few hundred pixels tall. If your sections are unusually short (dense FAQ accordions, for example) or unusually tall (long code-heavy tutorials), narrow or widen the band percentage accordingly — a band that is too wide will report several sections as active at once for long stretches of scrolling.
No section is intersecting at all. This happens naturally at the very top and bottom of the page, where the first or last section has not yet entered (or has already left) the centered band. Keep the previously active link highlighted in this case rather than clearing it, so the nav never shows no active state during normal scrolling — only reset it explicitly when the user navigates away from the page entirely.
FAQ
Why does a centered rootMargin band work better than threshold alone for scroll-spy?
A centered rootMargin band such as -40% top and -40% bottom shrinks the effective root down to a thin strip near the vertical middle of the viewport, so a section only registers as intersecting once it occupies the reading position. threshold alone cannot express a position constraint like this — it only expresses how much of the target must be visible, not where in the viewport that visibility must occur.
What do I do when two sections both report isIntersecting: true?
This happens when a short section fits entirely inside the rootMargin band alongside part of its neighbor. Resolve it by comparing intersectionRatio across all currently intersecting entries and choosing the highest, or by choosing the entry whose boundingClientRect.top is closest to zero. Do not rely on array order, since the browser does not guarantee entries arrive in document order.
How do I set aria-current correctly on the active nav link?
Set aria-current="true" (or aria-current="location" for landmark-style navigation) on the anchor matching the active section, and remove it from the previously active anchor in the same update. Update the attribute, not just a CSS class, so screen reader users navigating the nav list also perceive which section is current.
Do I need to unobserve sections once they have been visited?
No. Unlike a one-shot reveal animation, scroll-spy nav highlighting needs continuous tracking for the entire lifetime of the page, since the user can scroll back to any section at any time. Keep every section under observation and only call disconnect() on full page or component teardown.
Related
- Scroll-Driven Effects & Sticky Headers — parent guide covering the shared sentinel technique
- Building a Sticky Header with IntersectionObserver — the single-boundary variant of this same approach
- How IntersectionObserver threshold works in practice — threshold and rootMargin fundamentals referenced throughout this guide
- IntersectionObserver API Deep Dive — full option and entry reference
↑ Back to Scroll-Driven Effects & Sticky Headers