Scrollspy watches the scroll position of a page or container and adds the .active class to the navigation link whose section is currently in view. It powers the "on this page" sidebars of documentation sites and the highlighted menu items of single-page landing pages. This lesson covers the requirements, the two ways to activate it, nested navigation, list groups as targets, and the JavaScript API for content that changes after load.
Scrollspy only works when all of these are true:
.nav, .list-group or any container of .nav-link, .list-group-item or .dropdown-item elements (or plain anchors).href is an ID selector (#usage) that matches an element on the page.position: relative (the <body> already qualifies) and, when it is not the body, an explicit height with overflow-y: auto or scroll.Bootstrap 5.2 rewrote the plugin on top of the Intersection Observer API, so it is efficient and no longer needs an offset in most cases.
The simplest setup is a sticky navbar and sections on the page:
<body data-bs-spy="scroll" data-bs-target="#page-nav" tabindex="0">
<nav id="page-nav" class="sticky-top bg-body border-bottom">
<ul class="nav justify-content-center">
<li class="nav-item"><a class="nav-link" href="#features">Features</a></li>
<li class="nav-item"><a class="nav-link" href="#pricing">Pricing</a></li>
<li class="nav-item"><a class="nav-link" href="#contact">Contact</a></li>
</ul>
</nav>
<section id="features" style="min-height: 100vh">...</section>
<section id="pricing" style="min-height: 100vh">...</section>
<section id="contact" style="min-height: 100vh">...</section>
</body>data-bs-spy="scroll" activates the plugin on the scrolling element.data-bs-target points to the navigation container.tabindex="0" makes the scroll container focusable, which is needed for keyboard users.Because the navbar is sticky, the top of each section is hidden behind it when scrolled into place. Fix that with scroll-margin-top on the sections (or data-bs-root-margin, described below):
section[id] {
scroll-margin-top: 72px; /* navbar height */
}For a sidebar next to scrolling content:
<div class="row">
<div class="col-4">
<div id="side-nav" class="list-group sticky-top">
<a class="list-group-item list-group-item-action" href="#item-1">Item 1</a>
<a class="list-group-item list-group-item-action" href="#item-2">Item 2</a>
<a class="list-group-item list-group-item-action" href="#item-3">Item 3</a>
</div>
</div>
<div class="col-8">
<div data-bs-spy="scroll" data-bs-target="#side-nav" data-bs-smooth-scroll="true"
class="overflow-y-auto position-relative" style="height: 320px" tabindex="0">
<h4 id="item-1">Item 1</h4><p>...</p>
<h4 id="item-2">Item 2</h4><p>...</p>
<h4 id="item-3">Item 3</h4><p>...</p>
</div>
</div>
</div>The container needs a fixed height and overflow-y: auto so that it, not the page, scrolls. data-bs-smooth-scroll="true" animates the jump when a link is clicked.
Nested .nav elements are supported: when a child link activates, its parent link is activated too. This produces the two-level table of contents used in documentation:
<nav id="toc" class="flex-column align-items-stretch">
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="#grid">Grid</a>
<nav class="nav nav-pills flex-column ms-3">
<a class="nav-link" href="#grid-columns">Columns</a>
<a class="nav-link" href="#grid-gutters">Gutters</a>
</nav>
<a class="nav-link" href="#utilities">Utilities</a>
</nav>
</nav>| Option | Default | Purpose |
|---|---|---|
| target | | Selector of the navigation container |
| rootMargin | 0px 0px -25% | Intersection Observer margin; adjust for sticky headers |
| threshold | [0.1, 0.5, 1] | Visibility ratios that trigger updates |
| smoothScroll | false | Smooth scroll on link click |
import { ScrollSpy } from "bootstrap";
const spy = new ScrollSpy(document.body, {
target: "#page-nav",
rootMargin: "-72px 0px -40%"
});
// After adding or removing sections dynamically
spy.refresh();
// React to changes
document.body.addEventListener("activate.bs.scrollspy", (event) => {
console.log("Now viewing", event.relatedTarget); // the activated link
});refresh() is required whenever the page's sections change after initialization, for example when content is loaded via fetch or a collapse opens. activate.bs.scrollspy fires on the scroll container each time a new link becomes active.
overflow-y-auto with a fixed height, or spy on the body.scroll-margin-top on sections or a negative top rootMargin.refresh() after DOM updates. New sections are not observed until you call it.What must be true of the element that has `data-bs-spy="scroll"`?
data-bs-spy="scroll" and data-bs-target="#nav" to the scrolling element; nav links must point to existing IDs.position: relative; non-body containers also need a fixed height and overflow-y: auto.scroll-margin-top or rootMargin to account for sticky headers, and data-bs-smooth-scroll for animated jumps.refresh() after DOM changes and listen for activate.bs.scrollspy to react to section changes.Next lesson: Bootstrap Icons - add the official icon library via CDN, npm or inline SVG and size and color icons with utilities.