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();
});