requestAnimationFrame-based ScrollSpy

by Kuligaposten 2026-01-18

Want to change a navbar’s appearance? Scrollspy an optimized JavaScript solution

Building a ScrollSpy with requestAnimationFrame for Smooth, Predictable Navigation

ScrollSpy navigation is a classic UI pattern: as users scroll through a page, the navigation updates to reflect the section currently in view. While modern solutions often use IntersectionObserver, there are cases where a manual, scroll-position–based approach is preferable.

This script implements ScrollSpy using requestAnimationFrame (rAF), giving you fine-grained control over scroll behavior, predictable timing, and excellent performance when done correctly.

Let’s break down how it works.


1. Encapsulating Logic in a Class

class ScrollSpy {
  constructor() {

Unlike ad-hoc scripts, this version is organized as a class. This provides:

  • Clear separation of concerns
  • Easier cleanup (destroy())
  • Better scalability for complex layouts

The constructor handles setup, event binding, and the initial run.


2. Caching DOM Elements Up Front

this.sections = [...document.querySelectorAll("section[id]")];
this.menuLinks = document.querySelectorAll(".nav-item a");
this.mainNav = document.getElementById("mainNav");
this.navToggle = document.querySelector(".navbar-toggler");
this.modals = document.querySelectorAll(".modal");

Key elements are cached once to avoid repeated DOM queries during scrolling:

  • All page sections with IDs
  • Navigation links
  • The main navbar
  • Bootstrap modals

This is especially important for scroll-based logic, where performance matters.


3. rAF-Based Scroll Throttling

Why requestAnimationFrame?

Scroll events can fire dozens of times per frame. Instead of reacting to every event, this script batches updates using requestAnimationFrame.

this.ticking = false;
onScroll() {
  if (!this.ticking) {
    window.requestAnimationFrame(() => {
      this.scrollHandle();
      this.ticking = false;
    });
    this.ticking = true;
  }
}

This ensures:

  • At most one update per frame
  • Smoother animations
  • No unnecessary layout thrashing

This pattern is widely regarded as the correct way to handle heavy scroll logic.


4. Accounting for Fixed Navigation Height

updateNavOffset() {
  this.navOffset = this.mainNav ? this.mainNav.clientHeight : 0;
}

Since the navbar is fixed, section positions must be offset by its height. This method recalculates that offset:

  • On page load
  • On window resize
onResize() {
  this.updateNavOffset();
  this.onScroll();
}

5. Determining the Active Section

The core logic lives in scrollHandle().

Getting the Scroll Position

const scrollPosition =
  document.documentElement.scrollTop || document.body.scrollTop;

This ensures compatibility across browsers.


Shrinking the Navbar on Scroll

this.mainNav.classList.toggle("navbar-shrink", scrollPosition > 100);

A simple threshold-based toggle adds or removes the .navbar-shrink class.


Calculating Section Boundaries

for (let i = 0; i < this.sections.length; i++) {
  const current = this.sections[i];
  const next = this.sections[i + 1];

  const start = current.offsetTop - this.navOffset;
  const end = next ? next.offsetTop - this.navOffset : Infinity;

Each section defines a range:

  • start: where the section begins (accounting for the navbar)
  • end: where the next section begins

If the scroll position falls within that range, the section is considered active.


Handling the Top of the Page

if (scrollPosition < this.sections[0].offsetTop - this.navOffset) {
  activeSection = null;
}

This prevents any nav item from being active when the user is above the first section—ideal for hero headers.


6. Updating Active Navigation Links

this.menuLinks.forEach((link) => link.classList.remove("active"));

All links are reset before activating the correct one.

const target = document.querySelector(`a[href="#${activeSection.id}"]`);

Once the matching link is found, it’s marked active.


7. Supporting Dropdown Navigation

const dropdownMenu = target.closest(".dropdown-menu");
if (dropdownMenu) {
  dropdownMenu
    .closest(".nav-item")
    ?.querySelector("a")
    ?.classList.add("active");
}

If the active link lives inside a dropdown:

  • The parent dropdown toggle is also highlighted
  • Navigation state remains visually consistent

8. Bootstrap Modal Integration

Just like the IntersectionObserver version, this script integrates cleanly with Bootstrap modals:

modal.addEventListener("shown.bs.modal", () =>
  this.mainNav.classList.add("d-none"),
);
  • Navbar is hidden when a modal opens
  • Restored when it closes
modal.addEventListener("hide.bs.modal", () => document.activeElement.blur());

This small accessibility touch prevents focus from getting “stuck” after modal interactions.


9. Clean Setup and Teardown

Event Listeners

window.addEventListener("scroll", this.onScroll, false);
window.addEventListener("resize", this.onResize, false);

Destroy Method

destroy() {
  window.removeEventListener('scroll', this.onScroll);
  window.removeEventListener('resize', this.onResize);
}

This makes the ScrollSpy reusable in dynamic environments (SPAs, page transitions, etc.).


10. Initialization

document.addEventListener("DOMContentLoaded", () => {
  new ScrollSpy();
});

Everything boots once the DOM is ready, keeping behavior predictable and safe.


IntersectionObserver vs. requestAnimationFrame

This rAF-based approach shines when:

  • You need precise control over activation logic
  • You rely on fixed offsets and custom thresholds
  • You want consistent behavior across older browsers
  • Section visibility isn’t strictly viewport-based

IntersectionObserver is more declarative and efficient in many cases—but rAF remains a powerful, reliable alternative when you need full control.


Final Thoughts

This ScrollSpy implementation demonstrates how to:

  • Handle scroll events efficiently
  • Avoid performance pitfalls
  • Support dropdowns and modals
  • Maintain clean, class-based architecture

If you want total control over scroll behavior and timing, requestAnimationFrame is still one of the best tools in the frontend toolbox.

here is the full script

class ScrollSpy {
  constructor() {
    this.sections = [...document.querySelectorAll("section[id]")];
    this.menuLinks = document.querySelectorAll(".nav-item a");
    this.navCollapse = document.getElementById("navbarResponsive");
    this.mainNav = document.getElementById("mainNav");
    this.navToggle = document.querySelector(".navbar-toggler");
    this.modals = document.querySelectorAll(".modal");

    this.ticking = false;

    this.scrollHandle = this.scrollHandle.bind(this);
    this.onScroll = this.onScroll.bind(this);
    this.onResize = this.onResize.bind(this);

    this.updateNavOffset();
    this.addEventListeners();

    this.scrollHandle();
  }

  updateNavOffset() {
    this.navOffset = this.mainNav ? this.mainNav.clientHeight : 0;
  }

  onScroll() {
    if (!this.ticking) {
      window.requestAnimationFrame(() => {
        this.scrollHandle();
        this.ticking = false;
      });
      this.ticking = true;
    }
  }

  onResize() {
    this.updateNavOffset();
    this.onScroll();
  }

  scrollHandle() {
    const scrollPosition =
      document.documentElement.scrollTop || document.body.scrollTop;

    if (this.mainNav) {
      this.mainNav.classList.toggle("navbar-shrink", scrollPosition > 100);
    }

    let activeSection = null;

    for (let i = 0; i < this.sections.length; i++) {
      const current = this.sections[i];
      const next = this.sections[i + 1];

      const start = current.offsetTop - this.navOffset;
      const end = next ? next.offsetTop - this.navOffset : Infinity;

      if (scrollPosition >= start && scrollPosition < end) {
        activeSection = current;
        break;
      }
    }

    if (scrollPosition < this.sections[0].offsetTop - this.navOffset) {
      activeSection = null;
    }

    this.menuLinks.forEach((link) => link.classList.remove("text-bg-warning"));

    if (!activeSection) return;

    const target = document.querySelector(`a[href="#${activeSection.id}"]`);

    if (!target) return;

    target.classList.add("text-bg-warning");
    if (this.navCollapse?.classList.contains("show")) {
      this.navToggle?.click();
    }
    const dropdownMenu = target.closest(".dropdown-menu");
    if (dropdownMenu) {
      dropdownMenu
        .closest(".nav-item")
        ?.querySelector("a")
        ?.classList.add("active");
    }
  }

  addEventListeners() {
    window.addEventListener("scroll", this.onScroll, false);
    window.addEventListener("resize", this.onResize, false);
    this.modals.forEach((modal) => {
      modal.addEventListener("shown.bs.modal", () =>
        this.mainNav.classList.add("d-none"),
      );
      modal.addEventListener("hidden.bs.modal", () =>
        this.mainNav.classList.remove("d-none"),
      );
      modal.addEventListener("hide.bs.modal", () =>
        document.activeElement.blur(),
      );
    });
  }
  destroy() {
    window.removeEventListener("scroll", this.onScroll);
    window.removeEventListener("resize", this.onResize);
  }
}

document.addEventListener("DOMContentLoaded", () => {
  new ScrollSpy();
});
Back to Home