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:

  1. DOM caching and setup
  2. Mapping sections to nav links
  3. IntersectionObserver for scroll detection
  4. Controlled class toggling for active states
  5. 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);
  });
});
Back to Home