ScrollSpy in JavaScript
by Kuligaposten 2026-01-18
Want to change a navbar’s appearance? Scrollspy an optimized JavaScript solution
Building a Modern Scroll-Aware Navigation with IntersectionObserver
Highlighting the current section in a navigation bar sounds simple—until you factor in dropdowns, mobile menus, fixed headers, and performance. Many sites still rely on scroll event listeners for this, but modern browsers give us a better tool: IntersectionObserver.
In this post, we’ll walk through a JavaScript script that automatically updates navigation links as the user scrolls through the page, while also handling dropdown menus and mobile navigation states cleanly and efficiently.
The Problem This Script Solves
The script handles several common UI challenges:
- 🔹 Automatically marks the correct navigation link as active while scrolling
- 🔹 Works with anchor links (
href="#section-id") - 🔹 Supports dropdown menus in the navbar
- 🔹 Closes the mobile menu after navigation updates
- 🔹 Avoids expensive scroll event listeners
All of this happens without jank, polling, or manual scroll math.
High-Level Approach
Instead of watching the scroll position, the script observes when page sections enter the viewport. When a section becomes visible, the corresponding navigation link is activated.
The core building blocks are:
- DOM caching and setup
- Mapping sections to nav links
- IntersectionObserver for scroll detection
- Controlled class toggling for active states
- Optional mobile menu handling
Step 1: Wait for the DOM
document.addEventListener('DOMContentLoaded', () => {
We wait until the DOM is fully loaded before querying elements. This avoids race conditions and makes the script safe to include in the <head>.
Step 2: Cache Key Navigation Elements
const navBar = document.getElementById("mainNav");
if (!navBar) return;
If the navigation bar doesn’t exist, the script exits early—no unnecessary work.
We also cache:
- The collapsible nav container (for mobile)
- The navbar toggle button
This avoids repeated DOM lookups later.
Step 3: Map Section IDs to Navigation Links
const navLinks = new Map();
document.querySelectorAll('.navbar-nav a[href^="#"]').forEach((link) => {
const id = link.hash.slice(1);
if (id) navLinks.set(id, link);
});
Instead of searching the DOM every time a section becomes visible, we build a Map where:
- Key → section ID
- Value → corresponding navigation link
This gives us constant-time lookups when a section is intersecting.
Step 4: Managing Active and Dropdown States
Before activating a new link, we clean up the old state:
const clearActive = () => {
navBar
.querySelectorAll(".active")
.forEach((el) => el.classList.remove("active"));
navBar
.querySelectorAll(".nav-item.dropdown")
.forEach((el) => el.classList.remove("show"));
};
This ensures:
- Only one link is active at a time
- Dropdown menus don’t remain open accidentally
When activating a link, we also check if it belongs to a dropdown and open the parent menu if needed.
Step 5: Detecting Sections with IntersectionObserver
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = entry.target.id;
if (!id || id === activeId) continue;
clearActive();
activateLink(navLinks.get(id));
activeId = id;
closeMobileNav();
break;
}
});
This is the heart of the script.
Key details:
- We only respond when a section enters the viewport
- We ignore repeated activations of the same section
- We handle only the first visible section per observer cycle
- We close the mobile menu if it’s open
This approach is far more efficient than listening to scroll events.
Step 6: Accounting for a Fixed Navbar
rootMargin: `-${navBar.offsetHeight}px 0px -90% 0px`;
This offset compensates for a fixed navigation bar, ensuring sections are considered “active” at the right time—not hidden underneath the header.
Step 7: Observe All Sections
document.querySelectorAll("section[id]").forEach((section) => {
observer.observe(section);
});
Any section with an ID automatically participates in scroll tracking—no additional configuration required.
Why IntersectionObserver Is the Right Tool
Compared to traditional scroll listeners, this approach:
- Improves performance
- Avoids manual scroll math
- Is easier to reason about
- Scales well on long pages
- Works smoothly on mobile
Final Thoughts
This script demonstrates how a relatively small amount of JavaScript can create a polished, responsive navigation experience. By leveraging modern browser APIs and being intentional about DOM interactions, we get a solution that’s both robust and maintainable.
If you’re building a single-page layout, documentation site, or marketing page with anchored navigation, this pattern is well worth adopting.
Here is the full script
document.addEventListener("DOMContentLoaded", () => {
const navBar = document.getElementById("mainNav");
if (!navBar) return;
const navCollapse = document.getElementById("navcol-1");
const navToggle = document.querySelector(".navbar-toggler");
const navLinks = new Map();
document.querySelectorAll('.navbar-nav a[href^="#"]').forEach((link) => {
const id = link.hash.slice(1);
if (id) navLinks.set(id, link);
});
const activeElements = () => navBar.querySelectorAll(".active");
const dropdowns = () => navBar.querySelectorAll(".nav-item.dropdown");
let activeId = null;
const clearActive = () => {
activeElements().forEach((el) => el.classList.remove("active"));
dropdowns().forEach((el) => el.classList.remove("show"));
};
const activateLink = (link) => {
if (!link) return;
link.classList.add("active");
const dropdownMenu = link.closest(".dropdown-menu");
dropdownMenu?.closest(".dropdown")?.classList.add("show");
};
const closeMobileNav = () => {
if (navCollapse?.classList.contains("show")) {
navToggle?.click();
}
};
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const { id } = entry.target;
if (!id || id === activeId) continue;
clearActive();
activateLink(navLinks.get(id));
activeId = id;
closeMobileNav();
break;
}
},
{
rootMargin: `-${navBar.offsetHeight}px 0px -90% 0px`,
threshold: 0,
},
);
document.querySelectorAll("section[id]").forEach((section) => {
observer.observe(section);
});
});