Scrollspy Navigation — IntersectionObserver-Driven Active Section Tracking
Scrollspy Navigation — Active Link Tracks the Section in View · Navigation · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<nav class="spy-nav" aria-label="Page sections">
<a href="#overview" class="spy-link active" data-target="overview">Overview</a>
<a href="#pricing" class="spy-link" data-target="pricing">Pricing</a>
<a href="#security" class="spy-link" data-target="security">Security</a>
<a href="#faq" class="spy-link" data-target="faq">FAQ</a>
</nav>
<div class="spy-scroll" id="spyScroll">
<section id="overview" class="spy-section">
<h3>Overview</h3>
<p>A quick summary of the product, what it does, and who it's for. This section is intentionally tall so scrolling clearly moves between sections.</p>
<p>Scroll down inside this box — the nav link above updates to match whichever section is currently in view, not just whichever one you last clicked.</p>
</section>
<section id="pricing" class="spy-section">
<h3>Pricing</h3>
<p>Plans, tiers, and billing details live here. Scrollspy tracks this section becoming the dominant one in the viewport as you scroll past it.</p>
<p>Notice the active link changes exactly when this heading crosses the tracking line, not before and not after.</p>
</section>
<section id="security" class="spy-section">
<h3>Security</h3>
<p>Compliance certifications, data handling practices, and encryption details for security-conscious teams evaluating the product.</p>
</section>
<section id="faq" class="spy-section">
<h3>FAQ</h3>
<p>Answers to the most common questions, including support and integration options.</p>
<p>End of the scrollable content — the last section should stay active once you reach the bottom, even though it may not fill the whole viewport.</p>
</section>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.demo { width: 460px; max-width: 100%; display: flex; flex-direction: column; gap: 12px; }
.spy-nav { display: flex; gap: 4px; background: #f1f5f9; padding: 4px; border-radius: 10px; }
.spy-link { flex: 1; text-align: center; padding: 8px 6px; border-radius: 7px; font-size: 12px; font-weight: 700; color: #64748b; text-decoration: none; transition: background 0.15s, color 0.15s; }
.spy-link:hover { color: #334155; }
.spy-link.active { background: #fff; color: #4338ca; box-shadow: 0 1px 3px rgba(15,23,42,0.1); }
.spy-link:focus-visible { outline: 2px solid #6366f1; outline-offset: 1px; }
.spy-scroll { height: 320px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 14px; background: #fff; scroll-behavior: smooth; }
.spy-section { padding: 22px 20px; min-height: 260px; border-bottom: 1px solid #f1f5f9; }
.spy-section:last-child { border-bottom: none; min-height: 200px; }
.spy-section h3 { font-size: 15px; font-weight: 800; color: #111827; margin-bottom: 10px; }
.spy-section p { font-size: 12.5px; color: #64748b; line-height: 1.7; margin-bottom: 8px; }const scrollContainer = document.getElementById('spyScroll');
const links = Array.from(document.querySelectorAll('.spy-link'));
const sections = links.map((link) => document.getElementById(link.dataset.target));
function setActive(id) {
links.forEach((link) => link.classList.toggle('active', link.dataset.target === id));
}
// IntersectionObserver, not a scroll listener doing manual getBoundingClientRect
// math, drives the tracking. rootMargin shrinks the "viewport" IntersectionObserver
// measures against to a thin horizontal band near the top of the scroll container —
// a section only counts as "in view" once it crosses that band, which is what
// makes the active link change at a predictable, consistent point rather than
// whenever any sliver of a section happens to be visible anywhere on screen.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActive(entry.target.id);
}
});
},
{
root: scrollContainer,
rootMargin: '-10% 0px -75% 0px', // a thin band starting 10% from the top of the scroll box
threshold: 0,
}
);
sections.forEach((section) => observer.observe(section));
// Clicking a link still scrolls smoothly to the target, but does not
// hardcode setActive() — the IntersectionObserver above is the single
// source of truth for "which link is active," triggered naturally once
// the smooth-scroll lands the target section inside the tracking band.
links.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = document.getElementById(link.dataset.target);
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Scrollspy Navigation — Tracking the Active Section with IntersectionObserver

Scrollspy — highlighting the nav link for whichever section is currently in view — used to require a scroll event listener manually computing every section's getBoundingClientRect() on every scroll tick, an approach that's both a performance liability (scroll fires dozens of times a second) and a correctness headache (defining exactly when a section "counts" as active requires ad-hoc pixel-threshold math). This snippet uses IntersectionObserver instead, which is both faster and easier to reason about once you understand the one non-obvious trick that makes it work: rootMargin.
Why a plain "is any part of the section visible" check doesn't work
If a section counted as "active" the instant *any* pixel of it entered the viewport, two adjacent sections would frequently both be technically "visible" at once near their shared boundary, and picking one to highlight would feel arbitrary or flickery. What's actually wanted is: a section becomes active once it's crossed a specific, predictable line — typically somewhere near the top of the viewport, mimicking "the section currently at your eye line."
rootMargin: '-10% 0px -75% 0px' — shrinking the observed viewport into a thin band
IntersectionObserver's rootMargin shrinks (with negative values) or grows (with positive values) the area it treats as "the viewport" for intersection purposes, independent of the actual visible scroll container. A top margin of -10% and bottom margin of -75% together carve out a thin horizontal band starting 10% down from the top of the scroll container and ending at 25% down (100% − 75%) — a section only reports as isIntersecting: true once part of it crosses into that narrow band. This is what produces a single, predictable point at which the active link switches, rather than a fuzzy, multi-section-active zone.
root: scrollContainer, not the default browser viewport
By default, IntersectionObserver measures intersection against the browser's own viewport — but this demo scrolls inside an internal <div>, not the page itself. Passing root: scrollContainer tells the observer to measure against *that element's* visible bounds instead, which is what makes scrollspy work correctly for an internally-scrolling panel (a settings page with a scrollable content area, a modal with long content) rather than only for whole-page scrolling.
Clicking a link doesn't hardcode the active state — it lets the observer catch up naturally
The click handler only calls scrollIntoView({ behavior: 'smooth' }) — it deliberately does *not* also call setActive() directly. This keeps the observer as the single source of truth for which link is active: once the smooth scroll finishes landing the target section inside the tracking band, the observer's own callback fires and updates the active link exactly as if the user had scrolled there manually, avoiding any risk of the click-triggered state and the scroll-triggered state disagreeing with each other mid-animation.
Step by step
How to Use
- 1Scroll the content panelThe nav link above highlights automatically as each section crosses a tracking band near the top of the panel — no clicking required.
- 2Click a nav linkSmooth-scrolls to that section; the link becomes active naturally once the section crosses the tracking band, not instantly on click.
- 3Adjust the rootMargin bandChange the percentages in the IntersectionObserver options to move where in the viewport a section is considered "active" — a smaller bottom-margin percentage widens the tracking band.
- 4Apply root: scrollContainer for internal scroll panelsPoint root at any internally-scrolling element instead of leaving it as the default page viewport, if your sections scroll inside a div rather than the whole page.
- 5Add more sectionsAdd a new link with a matching data-target and a new section with that id — the existing observer setup picks it up automatically since it observes every mapped section generically.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
That approach fires on every scroll tick (dozens of times per second), forcing a layout read each time — a real performance cost on long pages. IntersectionObserver is purpose-built for exactly this "is this element in view" question and runs far more efficiently without manual per-tick math.
It shrinks or grows the area IntersectionObserver treats as "the viewport" for intersection checks. The specific values here carve out a thin horizontal band near the top of the scroll container — a section only becomes "active" once it crosses into that band, giving one predictable, consistent activation point rather than a fuzzy always-partially-true zone.
By default, IntersectionObserver measures against the browser's own viewport. Since this demo scrolls inside an internal div rather than the whole page, root must be set to that div so intersection is measured relative to its actual visible bounds.
To avoid two competing sources of truth for "which link is active." The click only triggers a smooth scroll; the observer's own callback naturally marks the link active once the target section crosses the tracking band, keeping behavior consistent regardless of whether the user scrolled manually or via a link click.
As long as any part of it crosses into the rootMargin-defined band, isIntersecting fires true for it just like any other section — its shorter height doesn't exempt it from the same tracking logic.
Yes — omit the root option entirely (or set it to null) to fall back to IntersectionObserver's default behavior of measuring against the actual browser viewport, which is the more common scrollspy setup for a full page rather than a scrollable div.
Yes. While the Scrollspy Navigation — Active Link Tracks the Section in View snippet gives you one pattern to copy, the free Responsive Navbar Builder at webdevpuneet.com/navbar-builder/ designs a whole navigation bar: logo, links and dropdown menus, a mobile hamburger menu (dropdown panel, slide-in drawer or full screen) and a scroll effect (sticky, shrink, hide on scroll or transparent). It has six presets, a live preview that runs the real code, and exports HTML + CSS + vanilla JS, React, Tailwind, Vue 3 or Angular — or you can click Fork & Edit to keep editing it in My Code.