EthicsComputerCourses
Join training +91 90532 10052

Scrollspy

Bootstrap · Lesson 26 of 40 Intermediate 7 min read Updated

Long pages need a map. Documentation sites, landing pages, college syllabus pages and long blog posts often have a menu of sections at the top or side, and the item for the section you are reading lights up as you scroll. Bootstrap's Scrollspy plugin does exactly that: it watches which section is on screen and adds the active class to the matching link.

In this lesson you will add Scrollspy to a scrollable box and to a whole page with a sticky navbar, build a nested table of contents, and control it from JavaScript with options, events and refresh().

How Scrollspy works

Scrollspy needs three things:

  1. A scrolling element with data-bs-spy="scroll". This can be <body> (the whole page scrolls) or a box with a fixed height and overflow-y: auto or scroll.
  2. A menu that the scrolling element points to with data-bs-target="#menuId". The menu can be a Bootstrap nav, a list group, or plain links.
  3. Links with href="#id" that match the id of a section inside the scrolling element. The href is what connects a link to its section.

Behind the scenes, Bootstrap 5.2 and later use the browser's IntersectionObserver API, which reports when an element enters or leaves the visible area, instead of measuring positions on every scroll event. That makes it light on the CPU, even on low-cost phones. Sections that are hidden (for example inside a closed tab) are ignored.

Scrollspy in a scrollable box

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Scrollspy in a box</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    .itinerary { height: 260px; overflow-y: auto; }
    .itinerary section { min-height: 220px; }
  </style>
</head>
<body>
  <div class="container py-4">
    <h1 class="h5">Goa trip: 4-day plan</h1>
    <nav id="days" class="nav nav-pills mb-2" aria-label="Days">
      <a class="nav-link" href="#day1">Day 1</a>
      <a class="nav-link" href="#day2">Day 2</a>
      <a class="nav-link" href="#day3">Day 3</a>
      <a class="nav-link" href="#day4">Day 4</a>
    </nav>
    <div class="itinerary border rounded p-3" data-bs-spy="scroll" data-bs-target="#days" data-bs-smooth-scroll="true" tabindex="0">
      <section id="day1"><h2 class="h6">Day 1: Arrive in Panaji</h2><p>Check in, walk through Fontainhas and try a fish thali for dinner.</p></section>
      <section id="day2"><h2 class="h6">Day 2: Old Goa churches</h2><p>Visit the Basilica of Bom Jesus in the morning, then a river cruise.</p></section>
      <section id="day3"><h2 class="h6">Day 3: South Goa beaches</h2><p>Palolem beach, kayaking and a sunset at Cabo de Rama fort.</p></section>
      <section id="day4"><h2 class="h6">Day 4: Spice farm and return</h2><p>Spice plantation tour, lunch, then the evening train to Pune.</p></section>
    </div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Scroll inside the box and the pills follow. Click a pill and the box scrolls smoothly to that day, because of data-bs-smooth-scroll="true". Two details matter:

  • The box has a fixed height and overflow-y: auto. Without them nothing scrolls, so nothing is spied.
  • tabindex="0" lets keyboard users focus the box and scroll it with the arrow keys. Add it whenever the scrolling box has no focusable element inside.

Scrollspy on a whole page

On landing pages, the scrolling element is the <body> and the menu is usually a sticky navbar. A sticky bar causes one classic bug: when you click a link, the browser scrolls the section's top edge to the top of the window, which is now under the navbar, so the heading is hidden. The CSS property scroll-padding-top on <html> fixes it by telling the browser to leave room at the top.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Page scrollspy</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    html { scroll-padding-top: 4.5rem; }   /* height of the sticky navbar plus a little space */
    section { min-height: 70vh; }
  </style>
</head>
<body data-bs-spy="scroll" data-bs-target="#topnav">
  <nav class="navbar navbar-expand bg-body-tertiary sticky-top border-bottom" aria-label="Page sections">
    <div class="container">
      <span class="navbar-brand">Lucknow Food Walk</span>
      <div class="navbar-nav" id="topnav">
        <a class="nav-link" href="#about">About</a>
        <a class="nav-link" href="#route">Route</a>
        <a class="nav-link" href="#book">Book</a>
      </div>
    </div>
  </nav>
  <main class="container">
    <section id="about" class="py-4"><h2>About the walk</h2><p>Three hours through Aminabad and Chowk, tasting kebabs, kulfi and chaat.</p></section>
    <section id="route" class="py-4"><h2>Route</h2><p>Start at Aminabad market, end at the Rumi Darwaza for photos.</p></section>
    <section id="book" class="py-4"><h2>Book a place</h2><p>Walks run every Saturday at 5 pm. ₹899 per person.</p></section>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

You do not need data-bs-smooth-scroll here: Bootstrap's Reboot styles already set scroll-behavior: smooth on the root element for users who have not asked their system to reduce motion, so normal anchor links glide. That is also why smooth scrolling stops for users with "reduce motion" switched on, which is the respectful default.

Nested contents lists and the JavaScript API

For long articles, a side table of contents with sub-items works best. Nest a nav inside another nav: when a child link becomes active, Scrollspy marks its parent link active too, so readers always see which chapter and which part they are in.

You can also create Scrollspy from code with new bootstrap.ScrollSpy(element, options) or bootstrap.ScrollSpy.getOrCreateInstance(element, options). The options are:

OptionDefaultMeaning
targetnoneThe menu element or a selector such as "#toc"
rootMargin"0px 0px -25%"Shrinks or grows the watched area, like CSS margin; the default ignores the bottom quarter of the scroll area
threshold[0.1, 0.5, 1]How much of a section must be visible (10%, 50%, 100%) before Scrollspy reacts
smoothScrollfalseSmooth scrolling when a menu link is clicked

Two more tools complete the API. The activate.bs.scrollspy event fires on the scrolling element every time a new link becomes active, and its relatedTarget property is that link. The refresh() method tells Scrollspy to look for sections again: call it after you add or remove sections, or after a hidden container becomes visible.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Nested table of contents</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    #article { height: 300px; overflow-y: auto; }
    #article section { min-height: 160px; }
    #toc .nav .nav-link { padding-left: 2rem; font-size: .875rem; }
  </style>
</head>
<body>
  <div class="container py-4">
    <p class="small text-body-secondary" id="reading" aria-live="polite">Reading: Introduction</p>
    <div class="row">
      <nav id="toc" class="col-4" aria-label="Contents">
        <nav class="nav nav-pills flex-column">
          <a class="nav-link" href="#intro">Introduction</a>
          <a class="nav-link" href="#rules">Rules of cricket</a>
          <nav class="nav nav-pills flex-column">
            <a class="nav-link" href="#batting">Batting</a>
            <a class="nav-link" href="#bowling">Bowling</a>
          </nav>
        </nav>
      </nav>
      <div class="col-8 border rounded p-3" id="article" tabindex="0">
        <section id="intro"><h2 class="h6">Introduction</h2><p>Cricket is played between two teams of eleven players.</p></section>
        <section id="rules"><h2 class="h6">Rules of cricket</h2><p>A match is divided into innings.</p></section>
        <section id="batting"><h2 class="h6">Batting</h2><p>Batters score runs by running between the wickets.</p></section>
        <section id="bowling"><h2 class="h6">Bowling</h2><p>An over has six legal deliveries.</p></section>
      </div>
    </div>
    <button type="button" class="btn btn-sm btn-outline-primary mt-3" id="addBtn">Add a "Fielding" section</button>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    const article = document.getElementById("article");
    const spy = bootstrap.ScrollSpy.getOrCreateInstance(article, { target: "#toc", smoothScroll: true });

    article.addEventListener("activate.bs.scrollspy", (event) => {
      document.getElementById("reading").textContent = "Reading: " + event.relatedTarget.textContent;
    });

    document.getElementById("addBtn").addEventListener("click", (event) => {
      const section = document.createElement("section");
      section.id = "fielding";
      section.innerHTML = '<h2 class="h6">Fielding</h2><p>Fielders stop runs and take catches.</p>';
      article.append(section);
      const link = document.createElement("a");
      link.className = "nav-link";
      link.href = "#fielding";
      link.textContent = "Fielding";
      document.querySelector("#toc nav nav").append(link);
      spy.refresh();              // look for sections again
      event.currentTarget.disabled = true;
    });
  </script>
</body>
</html>

Scroll to "Batting" and both "Batting" and its parent "Rules of cricket" are highlighted. Press the button, scroll to the bottom, and "Fielding" lights up too, because refresh() registered the new section. (The innerHTML here uses fixed text written by the developer, not user input, so it is safe.)

Older code

Up to Bootstrap 5.1, Scrollspy measured positions on every scroll and had offset and method options. From 5.2 they are replaced by rootMargin. A data-bs-offset value is still converted into a rootMargin for backward compatibility, but the Bootstrap source marks this as something to remove in version 6, so do not write new code with it. Bootstrap 4 used data-spy="scroll", data-target and $('body').scrollspy({ target: '#nav' }) with jQuery; in Bootstrap 5 these become data-bs-spy, data-bs-target and new bootstrap.ScrollSpy(el, { target: "#nav" }).

Common mistakes

  • The box does not scroll. Without a set height and overflow-y, there is no scrolling to spy on. Use <body> or give the box a height.
  • Links and ids do not match. href="#day-1" will never find id="day1". Check every pair.
  • Headings hidden under a sticky navbar. Add scroll-padding-top to <html>.
  • Adding sections later and forgetting refresh(). The new sections are not watched until you call it.
  • The last item never becomes active. The page is too short to scroll that far; add space at the bottom.

Key points

  • Put data-bs-spy="scroll" and data-bs-target="#menu" on the scrolling element, and match each link's href to a section id.
  • The scrolling element is <body> or a box with a fixed height, overflow-y and tabindex="0".
  • Bootstrap 5.2+ uses IntersectionObserver, tuned with rootMargin and threshold.
  • Nested navs highlight both the child and the parent link.
  • activate.bs.scrollspy reports the new link in event.relatedTarget; refresh() picks up added or removed sections.
  • Use scroll-padding-top on <html> when a sticky navbar covers section headings.

Exercise

Build a one-page "Kochi Book Fair" site with a sticky navbar holding four links (Dates, Stalls, Events, Contact) and four sections. Make Scrollspy highlight the current link, stop headings from hiding under the navbar, and show the current section's name in the browser tab title (for example "Stalls | Kochi Book Fair") using the activate.bs.scrollspy event.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Kochi Book Fair</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    html { scroll-padding-top: 4.5rem; }
    section { min-height: 70vh; }
  </style>
</head>
<body data-bs-spy="scroll" data-bs-target="#links">
  <nav class="navbar navbar-expand bg-dark sticky-top" data-bs-theme="dark" aria-label="Sections">
    <div class="container">
      <span class="navbar-brand">Kochi Book Fair</span>
      <div class="navbar-nav" id="links">
        <a class="nav-link" href="#dates">Dates</a>
        <a class="nav-link" href="#stalls">Stalls</a>
        <a class="nav-link" href="#events">Events</a>
        <a class="nav-link" href="#contact">Contact</a>
      </div>
    </div>
  </nav>
  <main class="container">
    <section id="dates" class="py-4"><h2>Dates</h2><p>5 to 12 December, Marine Drive grounds.</p></section>
    <section id="stalls" class="py-4"><h2>Stalls</h2><p>Over a hundred publishers in Malayalam and English.</p></section>
    <section id="events" class="py-4"><h2>Events</h2><p>Author talks every evening at 6 pm.</p></section>
    <section id="contact" class="py-4"><h2>Contact</h2><p>Visit the help desk near Gate 1.</p></section>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    document.body.addEventListener("activate.bs.scrollspy", (event) => {
      document.title = event.relatedTarget.textContent + " | Kochi Book Fair";
    });
  </script>
</body>
</html>

The body is the scrolling element, so the event fires on document.body; relatedTarget is the link that just became active.

Quick Quiz

1. What connects a Scrollspy menu link to the section it represents?

2. Which browser API does Scrollspy use from Bootstrap 5.2 onwards?

3. You add a new section with JavaScript, but its link never becomes active. What should you call?

4. With a sticky navbar, clicked sections hide their headings under the bar. Which CSS fixes this?

Learn Bootstrap with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call