Responsive Sidebar ScrollSpy

by Kuligaposten May 27 2025

Building a Responsive Sidebar ScrollSpy with JavaScript and Bootstrap

Responsive Sidebar

Modern websites often feature long-scrolling pages with multiple sections, and it’s common UX practice to offer a navigation sidebar that highlights the section currently in view. Enter the ScrollSpy—a behavior popularized by Bootstrap that dynamically updates navigation links as users scroll.

In this post, we’ll walk through a custom implementation of a responsive sidebar ScrollSpy using JavaScript and Bootstrap’s offcanvas component. Let’s explore the class SidebarScrollSpy, how it works, and how you can use it in your next project.


What Is SidebarScrollSpy?

SidebarScrollSpy is a JavaScript class designed to:

  • Dynamically highlight navigation links based on scroll position.
  • Automatically toggle a Bootstrap offcanvas menu depending on screen size.
  • Handle menu closing on link click for smaller screens.
  • Smoothly handle performance by throttling scroll events.

It’s designed for responsiveness and works with Bootstrap’s offcanvas component, making it a great solution for mobile-friendly navigation.


Code Breakdown

Here’s what the class does under the hood:

1. Constructor Setup

  constructor({ menuSelector, menuThreshold = 992 }) {
    this.menu = document.querySelector(menuSelector);
    if (!this.menu) {
      throw new Error(`Menu element with selector "${menuSelector}" not found`);
    }
    if (typeof bootstrap === 'undefined' || !bootstrap.Offcanvas) {
      throw new Error('Bootstrap Offcanvas is not available');
    }
    this.menuThreshold = menuThreshold;
    this.links = this.menu.querySelectorAll('.nav-link');
    this.sections = [...document.querySelectorAll('section[id]')];
    if (!this.sections.length) {
      console.warn('No sections with IDs found for scroll-spy');
    }
    this.offcanvas = bootstrap.Offcanvas.getOrCreateInstance(this.menu);
    this.handleResize = this.handleResize.bind(this);
    this.handleScroll = this.handleScroll.bind(this);
    this.handleClick = this.handleClick.bind(this);

    this.bindEvents();
    this.handleResize();
    this.handleScroll();
  }
  • menuSelector: CSS selector for the offcanvas sidebar.
  • menuThreshold: Width breakpoint where the sidebar switches between offcanvas (mobile) and static (desktop).

The constructor grabs references to:

  • Navigation links (.nav-link)
  • Page sections (section[id])
  • The Bootstrap offcanvas instance

2. Responsive Behavior: handleResize()

  handleResize() {
    const isLarge = window.innerWidth >= this.menuThreshold;
    const isShown = this.menu.classList.contains('show');

    if (isLarge && !isShown) {
      this.offcanvas.show();
    } else if (!isLarge && isShown) {
      this.offcanvas.hide();
    }
  }

This method ensures the sidebar is visible on large screens and collapsible on small screens. It uses Bootstrap's Offcanvas API to manage visibility.

3. Scroll Tracking: handleScroll()

  handleScroll() {
    const scrollPosition = window.scrollY || window.pageYOffset;
    let activeSection = null;

    for (let i = this.sections.length - 1; i >= 0; i--) {
      const section = this.sections[i];
      const offset = section.offsetTop;

      if (scrollPosition >= offset) {
        activeSection = section;
        break;
      }
    }

    this.links.forEach((link) => {
      link.classList.remove('active');
      link.removeAttribute('aria-current');
    });

    if (activeSection) {
      const target = this.menu.querySelector(`a[href="#${activeSection.id}"]`);
      if (target?.hash) {
        target.classList.add('active');
        target.setAttribute('aria-current', 'true');
        if (target.closest('.dropdown-menu')) {
          const parentLink = target
            .closest('.dropdown')
            ?.querySelector('.nav-link');
          parentLink?.classList.add('active');
          parentLink?.setAttribute('aria-current', 'true');
        }
      }
    }
  }

This method compares the current scroll position with section offsets, activates the relevant nav link, and supports nested dropdowns by applying the active class appropriately.

4. Click-to-Close on Small Screens

handleClick() {
  const isSmall = window.innerWidth <= this.menuThreshold;
  if (isSmall) this.offcanvas.hide();
}

When a nav link is clicked on smaller screens, the sidebar closes to free up screen space.

5. Throttling Scroll Events

  throttle(callback, delay) {
    let lastCall = 0;
    return () => {
      const now = Date.now();
      if (now - lastCall >= delay) {
        lastCall = now;
        callback();
      }
    };
  }

Scroll events can fire dozens of times per second. This helper function throttles the scroll handler to run at most once every 100ms.

6. Binding Events

  bindEvents() {
    window.addEventListener('resize', () =>
      requestAnimationFrame(this.handleResize)
    );
    window.addEventListener('load', this.handleResize);
    document.addEventListener('scroll', this.throttle(this.handleScroll, 100));
    this.links.forEach((link) =>
      link.addEventListener('click', this.handleClick)
    );
  }

Sets up event listeners for:

  • resize and load for responsive behavior
  • scroll for updating nav links
  • click on nav links to close the menu when needed

How to Use

To activate the SidebarScrollSpy, instantiate it like this:

document.addEventListener("DOMContentLoaded", () => {
  new SidebarScrollSpy({
    menuSelector: "#offcanvas-menu",
    menuThreshold: 992,
  });
});

Make sure your HTML has:

  • An offcanvas element with nav links using .nav-link and href="#section-id"
  • Sections on the page with corresponding ids

Example:

<a class="nav-link" href="#about">About</a>
...
<section id="about">...</section>

Final Thoughts

This SidebarScrollSpy class gives you full control over sidebar navigation in a clean, modular way—without relying heavily on Bootstrap’s built-in ScrollSpy, which is limited when working with custom or responsive layouts.

If your website or web app needs an offcanvas sidebar that adapts to screen size and updates intelligently as users scroll, this solution is a strong, lightweight choice.

Here is the whole script

class SidebarScrollSpy {
  constructor({ menuSelector, menuThreshold = 992 }) {
    this.menu = document.querySelector(menuSelector);
    if (!this.menu) {
      throw new Error(`Menu element with selector "${menuSelector}" not found`);
    }
    if (typeof bootstrap === "undefined" || !bootstrap.Offcanvas) {
      throw new Error("Bootstrap Offcanvas is not available");
    }
    this.menuThreshold = menuThreshold;
    this.links = this.menu.querySelectorAll(".nav-link");
    this.sections = [...document.querySelectorAll("section[id]")];
    if (!this.sections.length) {
      console.warn("No sections with IDs found for scroll-spy");
    }
    this.offcanvas = bootstrap.Offcanvas.getOrCreateInstance(this.menu);
    this.handleResize = this.handleResize.bind(this);
    this.handleScroll = this.handleScroll.bind(this);
    this.handleClick = this.handleClick.bind(this);

    this.bindEvents();
    this.handleResize();
    this.handleScroll();
  }

  bindEvents() {
    window.addEventListener("resize", () =>
      requestAnimationFrame(this.handleResize),
    );
    window.addEventListener("load", this.handleResize);
    document.addEventListener("scroll", this.throttle(this.handleScroll, 100));
    this.links.forEach((link) =>
      link.addEventListener("click", this.handleClick),
    );
  }

  destroy() {
    window.removeEventListener("resize", this.handleResize);
    window.removeEventListener("load", this.handleResize);
    document.removeEventListener("scroll", this.handleScroll);
    this.links.forEach((link) =>
      link.removeEventListener("click", this.handleClick),
    );
  }

  handleResize() {
    const isLarge = window.innerWidth >= this.menuThreshold;
    const isShown = this.menu.classList.contains("show");

    if (isLarge && !isShown) {
      this.offcanvas.show();
    } else if (!isLarge && isShown) {
      this.offcanvas.hide();
    }
  }

  handleClick() {
    const isSmall = window.innerWidth <= this.menuThreshold;
    if (isSmall) this.offcanvas.hide();
  }

  handleScroll() {
    const scrollPosition = window.scrollY || window.pageYOffset;
    let activeSection = null;

    for (let i = this.sections.length - 1; i >= 0; i--) {
      const section = this.sections[i];
      const offset = section.offsetTop;

      if (scrollPosition >= offset) {
        activeSection = section;
        break;
      }
    }

    this.links.forEach((link) => {
      link.classList.remove("active");
      link.removeAttribute("aria-current");
    });

    if (activeSection) {
      const target = this.menu.querySelector(`a[href="#${activeSection.id}"]`);
      if (target?.hash) {
        target.classList.add("active");
        target.setAttribute("aria-current", "true");
        if (target.closest(".dropdown-menu")) {
          const parentLink = target
            .closest(".dropdown")
            ?.querySelector(".nav-link");
          parentLink?.classList.add("active");
          parentLink?.setAttribute("aria-current", "true");
        }
      }
    }
  }

  throttle(callback, delay) {
    let lastCall = 0;
    return () => {
      const now = Date.now();
      if (now - lastCall >= delay) {
        lastCall = now;
        callback();
      }
    };
  }
}

document.addEventListener("DOMContentLoaded", () => {
  new SidebarScrollSpy({
    menuSelector: "#offcanvas-menu",
    menuThreshold: 992,
  });
});
Back to Home