Bootstrap 5 Scrollspy for Long One-Page Sites

Bootstrap 5 Scrollspy for Long One-Page Sites

Key Takeaways

  • Bootstrap 5 Scrollspy is a pure data-attribute component: no custom JavaScript is required for the basic implementation.
  • The rootMargin option (introduced in Bootstrap 5.2) replaces the older offset option and maps directly to the IntersectionObserver API.
  • A fixed or sticky navbar changes the scroll offset calculation and must be compensated with rootMargin or a scroll-margin-top CSS rule.
  • Scrollspy requires the scrollable container to have a defined height and overflow-y: auto or overflow-y: scroll when watching a non-body element.
  • CSS custom properties make it straightforward to style the active navigation link to match your brand without overriding Bootstrap’s compiled CSS.

How Bootstrap 5 Scrollspy Works

Scrollspy watches a scrollable element (usually document.body) and, as the user scrolls, identifies which target section is currently in view. It then adds Bootstrap’s .active class to the corresponding navigation link. Bootstrap 5.2 and later use the browser’s native IntersectionObserver rather than the scroll-event listener approach from Bootstrap 4. That change matters for two reasons: performance is significantly better, and the configuration API changed.

The two key attributes are:

  • data-bs-spy="scroll" placed on the scrollable container (usually <body>).
  • data-bs-target="#myNav" pointing to the ID of the nav element whose links should receive .active.

Every tracked section must have a unique id. Each nav link’s href must match one of those IDs exactly.

Bootstrap 5 Scrollspy for Long One-Page Sites, abstract concept illustration

Minimal Working Example

The markup below shows a complete, minimal one-page structure. The <body> element carries the Scrollspy attributes, and the fixed navbar sits outside the scroll container so it does not interfere with height calculations.

<!-- Fixed top navbar -->
<nav id="mainNav" class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
  <div class="container">
    <a class="navbar-brand" href="#">Brand</a>
    <ul class="navbar-nav ms-auto">
      <li class="nav-item"><a class="nav-link" href="#about">About</a></li>
      <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>
  </div>
</nav>

<!-- Scrollspy target: the body itself -->
<body
  data-bs-spy="scroll"
  data-bs-target="#mainNav"
  data-bs-root-margin="0px 0px -40%"
  data-bs-smooth-scroll="true"
  tabindex="0">

  <section id="about" class="py-5">...</section>
  <section id="features" class="py-5">...</section>
  <section id="pricing" class="py-5">...</section>
  <section id="contact" class="py-5">...</section>

</body>

data-bs-root-margin="0px 0px -40%" tells the IntersectionObserver to treat the bottom 40% of the viewport as outside the detection zone. A section is only considered active once its top edge has moved well into the viewport, not merely crossed the fold. Adjust the percentage to suit your section heights.

The Fixed Navbar Offset Problem

A fixed navbar is the single most common source of Scrollspy bugs. When a user clicks a nav link and the page jumps to a section, the navbar covers the top of that section. Two things go wrong: content is hidden behind the bar, and Scrollspy may mark the wrong link as active because the section’s top edge is technically scrolled past the viewport top.

There are two clean solutions.

Option 1: CSS scroll-margin-top on every section. This is the recommended modern approach. It does not affect layout at all, only where the browser lands after an anchor jump.

<style>
  / Replace 70px with your actual navbar height /
  section[id] {
    scroll-margin-top: 70px;
  }
</style>

Option 2: rootMargin top offset. Set the first value in data-bs-root-margin to a negative pixel value equal to the navbar height. This shifts the IntersectionObserver’s detection boundary down so it ignores the area covered by the navbar.

data-bs-root-margin="-70px 0px -40%"

In practice, combining both gives the cleanest result. scroll-margin-top handles the visual anchor jump; a negative top rootMargin handles the active-link detection timing.

one page navigation active link, abstract technical diagram

Bootstrap adds .active to the matching .nav-link automatically, using Bootstrap’s primary colour by default. On most real projects you will want to match the active state to your brand. Rather than overriding compiled CSS, use CSS custom properties to keep changes isolated.

<style>
  #mainNav .nav-link.active {
    color: var(--cnvs-themecolor, #0d6efd);
    font-weight: 600;
    border-bottom: 2px solid var(--cnvs-themecolor, #0d6efd);
  }
</style>

The fallback value #0d6efd (Bootstrap’s default primary) ensures the style degrades gracefully if --cnvs-themecolor is not set. If you are working with the Canvas HTML Template, the variable is already declared globally, so only the property declaration is needed. For a deeper look at theming multiple projects from one template using CSS variables, see our guide on theming one HTML template for multiple client brands with CSS variables.

Documentation pages and long article layouts often place a sticky sidebar nav next to a scrollable content column. In that pattern, Scrollspy watches a specific element rather than the body. Two conditions are required: the container must have an explicit height (or max-height), and it must have overflow-y: scroll or overflow-y: auto. Without these, the IntersectionObserver cannot detect scroll position changes because the element has no internal scroll to observe.

<div class="row">
  <div class="col-3">
    <nav id="sideNav">
      <ul class="nav flex-column">
        <li class="nav-item"><a class="nav-link" href="#section-one">Section One</a></li>
        <li class="nav-item"><a class="nav-link" href="#section-two">Section Two</a></li>
        <li class="nav-item"><a class="nav-link" href="#section-three">Section Three</a></li>
      </ul>
    </nav>
  </div>
  <div
    class="col-9"
    data-bs-spy="scroll"
    data-bs-target="#sideNav"
    data-bs-root-margin="0px 0px -30%"
    style="height: 80vh; overflow-y: scroll;"
    tabindex="0">
    <div id="section-one">...</div>
    <div id="section-two">...</div>
    <div id="section-three">...</div>
  </div>
</div>

tabindex="0" is included on the scrollable container because Bootstrap’s Scrollspy requires the element to be focusable when it is not the body. It is also an accessibility consideration: keyboard users can tab to the container and scroll it with arrow keys.

Refreshing Scrollspy After DOM Changes

If sections are added or removed after page load (an accordion that expands to reveal more content, for example), Scrollspy will not automatically recalculate target positions. You need to call refresh() manually.

<script>
  // Get the Scrollspy instance attached to the body
  const scrollSpy = bootstrap.ScrollSpy.getInstance(document.body);

  // Call after a DOM change that affects section heights
  document.getElementById('expandBtn').addEventListener('click', function () {
    // ... expand your content ...
    scrollSpy.refresh();
  });
</script>

If Scrollspy was initialised via data attributes (the most common case), getInstance will find the existing instance. If you initialised it via JavaScript with new bootstrap.ScrollSpy(), store the returned instance in a variable and call .refresh() on it directly.

This pattern is particularly important in single-page applications that use hash-based routing, or in pages where content loads asynchronously. It also applies to resume or portfolio pages with collapsible sections. For a practical example of a structured one-page layout, see our post on how to build a resume site with the Canvas Resume Demo.

When Not to Use Bootstrap Scrollspy

Scrollspy is the right tool when your navigation links and content sections share the same page and the same scroll container. There are scenarios where it is the wrong choice.

  • Multi-page sites: If each section lives on a separate URL, use server-side active-link logic or a small JavaScript router instead. Scrollspy cannot track cross-page position.
  • Very short sections: If two or three sections are visible simultaneously in a tall viewport (on a large monitor, for instance), Scrollspy may flicker between active states rapidly. Consider increasing section min-height or tightening rootMargin so only one section triggers at a time.
  • Horizontal scroll layouts: Scrollspy only supports vertical scroll. Horizontal parallax or carousel-style layouts need a custom IntersectionObserver implementation.
  • Sites with heavy client-side rendering: If your framework re-renders the DOM on each route change, Bootstrap’s data-attribute initialisation may be wiped. You would need to reinitialise Scrollspy after each render cycle, at which point a framework-native solution is likely simpler. For a broader comparison of static and JavaScript-framework approaches, see our article on static HTML vs React for marketing sites.

Frequently Asked Questions

The most common cause is a mismatch between section IDs in your HTML and the href values in your nav links, including case sensitivity. The second most common cause is that the scrollable container is not the element carrying data-bs-spy="scroll". If you have a wrapper div with overflow: hidden between the body and your sections, the body never actually scrolls and Scrollspy cannot detect movement. Check your CSS for any element between <body> and your sections that might be taking over the scroll behaviour.

data-bs-offset was deprecated in Bootstrap 5.2.0 when the component switched from a scroll-event listener to IntersectionObserver. Its replacement is data-bs-root-margin, which accepts the same syntax as the CSS margin shorthand and maps directly to the IntersectionObserver rootMargin option. If you are upgrading from Bootstrap 5.1 or earlier, replace any data-bs-offset attribute with data-bs-root-margin using a negative top value equivalent to your old offset in pixels.

Yes. Bootstrap’s Scrollspy works with any element that contains anchor tags with matching href values. List Groups, nav pills, and plain unordered lists all work as targets. Point data-bs-target at the ID of the container element (the .list-group wrapper, for example) rather than at individual items, and Bootstrap will manage the .active class on each child anchor automatically.

In Bootstrap 5.2 and later: minimally. The switch to IntersectionObserver means the browser handles detection natively at a low level, without firing JavaScript on every scroll pixel. The scroll-event approach in Bootstrap 4 and Bootstrap 5.0 to 5.1 could cause performance issues on large pages because the handler fired continuously. If you are still on an older Bootstrap minor version, upgrading to 5.2 or later is worthwhile for this reason alone.

Bootstrap 5.2 introduced the data-bs-smooth-scroll="true" attribute specifically for this. Adding it to the element carrying data-bs-spy="scroll" enables smooth scrolling for all anchor links targeting sections on that page, without any custom JavaScript. If you need more control over easing or duration, remove this attribute and use the CSS property scroll-behavior: smooth on the html element or the scrollable container instead. Note that scroll-behavior: smooth is not supported in Internet Explorer, but IE support is no longer a Bootstrap 5 requirement.

Looking for a production-ready Bootstrap 5 HTML template? Browse Canvas Template demos and find the perfect starting point for your next project.

If you’re building with the Canvas HTML Template and want to ship production-ready Bootstrap 5 layouts faster, try Canvas Builder free — the visual builder that exports clean Canvas-ready markup in minutes.

Skip the setup and build it free

Spin up a complete Bootstrap 5 site, blog included, with Canvas Builder. No coding, no cost.

Share:
Canvas Team
Canvas Team

Tutorials and tips for building beautiful Bootstrap 5 websites with the Canvas HTML Template and Canvas Builder.

More from the Canvas Blog