Scrollspy
Update Bootstrap navigation links as a scrollable region moves between sections.
<div class="w-100">
<nav id="demo-scrollspy-nav" class="navbar bg-body-tertiary px-3 mb-3 rounded">
<span class="navbar-brand">Scrollspy</span>
<ul class="nav nav-pills">
<li class="nav-item"><a class="nav-link" href="#demo-item-1">First</a></li>
<li class="nav-item"><a class="nav-link" href="#demo-item-2">Second</a></li>
<li class="nav-item"><a class="nav-link" href="#demo-item-3">Third</a></li>
</ul>
</nav>
<twig:Scrollspy target="#demo-scrollspy-nav" height="260px" class="border rounded p-3">
{% for number in 1..3 %}
<h4 id="demo-item-{{ number }}">Section {{ number }}</h4>
<p>Scroll this region to update the active navigation item.</p>
<p class="mb-5">The observed content stays inside its own scrollable container.</p>
{% endfor %}
</twig:Scrollspy>
</div>
Installation
php bin/console ux:install scrollspy --kit bootstrap
Install the following front-end dependencies:
npm install bootstrap@^5.3.0
# ...or with Symfony AssetMapper
php bin/console importmap:require bootstrap bootstrap/dist/css/bootstrap.min.css
Copy the following file(s) into your app:
{# @prop target string The CSS selector of the navigation element controlled by Scrollspy. #}
{# @prop height string The height of the scrollable region. #}
{# @prop smoothScroll boolean Whether link activation scrolls smoothly to its target section. #}
{# @prop rootMargin string|null The optional Intersection Observer root margin. #}
{# @prop threshold string|null The optional comma-separated Intersection Observer thresholds. #}
{# @block content The sections observed by Scrollspy. #}
{%- props
target,
height = '200px',
smoothScroll = false,
rootMargin = null,
threshold = null
-%}
{%- set default_attrs = {
class: 'position-relative overflow-auto',
style: 'height: ' ~ height ~ ';',
tabindex: '0',
'data-bs-spy': 'scroll',
'data-bs-target': target,
} -%}
{%- if smoothScroll -%}
{%- set default_attrs = default_attrs|merge({'data-bs-smooth-scroll': 'true'}) -%}
{%- endif -%}
{%- if rootMargin is not null -%}
{%- set default_attrs = default_attrs|merge({'data-bs-root-margin': rootMargin}) -%}
{%- endif -%}
{%- if threshold is not null -%}
{%- set default_attrs = default_attrs|merge({'data-bs-threshold': threshold}) -%}
{%- endif -%}
<div {{ attributes.defaults(default_attrs) }}>
{%- block content %}{% endblock -%}
</div>
Usage
<div>
<nav id="usage-scrollspy-nav" class="nav nav-pills mb-3">
<a class="nav-link" href="#usage-first">First</a>
<a class="nav-link" href="#usage-second">Second</a>
</nav>
<twig:Scrollspy target="#usage-scrollspy-nav" class="border rounded p-3">
<h4 id="usage-first">First section</h4>
<p>Scrollspy activates the navigation link for the section currently in view.</p>
<div style="min-height: 150px"></div>
<h4 id="usage-second">Second section</h4>
<p>Every link must point to a matching section identifier.</p>
<div style="min-height: 150px"></div>
</twig:Scrollspy>
</div>
The target prop must select the navigation element, and every navigation link must point to the ID of a section inside the scrollable component. Bootstrap initializes Scrollspy through its data API.
Accessibility
The component adds tabindex="0" so a scrollable region without focusable children remains keyboard-accessible. Use meaningful link labels, unique section IDs, and a logical heading hierarchy inside the observed content.
An active navigation style is a visual enhancement, not a substitute for headings or document structure. When smooth scrolling is enabled, Bootstrap respects prefers-reduced-motion through CSS.
Examples
Navbar
Track sections from links inside a Bootstrap navbar.
<div class="w-100">
<nav id="navbar-scrollspy" class="navbar bg-body-tertiary px-3 mb-3 rounded">
<span class="navbar-brand">Navbar</span>
<ul class="nav nav-pills">
<li class="nav-item"><a class="nav-link" href="#navbar-first">First</a></li>
<li class="nav-item"><a class="nav-link" href="#navbar-second">Second</a></li>
<li class="nav-item"><a class="nav-link" href="#navbar-third">Third</a></li>
</ul>
</nav>
<twig:Scrollspy target="#navbar-scrollspy" class="border rounded p-3">
{% for id, title in {first: 'First heading', second: 'Second heading', third: 'Third heading'} %}
<h4 id="navbar-{{ id }}">{{ title }}</h4>
<p>Representative content for {{ title|lower }}. Keep scrolling to activate the next link.</p>
<div style="min-height: 130px"></div>
{% endfor %}
</twig:Scrollspy>
</div>
Nested nav
Activate both parent and nested navigation links for hierarchical content.
<div class="row">
<div class="col-4">
<nav id="nested-scrollspy" class="h-100 flex-column align-items-stretch pe-3 border-end">
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="#nested-item-1">Item 1</a>
<nav class="nav nav-pills flex-column ms-3">
<a class="nav-link my-1" href="#nested-item-1-1">Item 1-1</a>
<a class="nav-link my-1" href="#nested-item-1-2">Item 1-2</a>
</nav>
<a class="nav-link" href="#nested-item-2">Item 2</a>
</nav>
</nav>
</div>
<div class="col-8">
<twig:Scrollspy target="#nested-scrollspy" height="260px" smoothScroll class="p-2">
{% for id in ['1', '1-1', '1-2', '2'] %}
<h4 id="nested-item-{{ id }}">Item {{ id }}</h4>
<p>Nested navigation activates parent and child links as their sections enter view.</p>
<div style="min-height: 120px"></div>
{% endfor %}
</twig:Scrollspy>
</div>
</div>
List group
Use a list group as the navigation target.
<div class="row">
<div class="col-4">
<div id="list-scrollspy" class="list-group">
<a class="list-group-item list-group-item-action" href="#list-item-1">Item 1</a>
<a class="list-group-item list-group-item-action" href="#list-item-2">Item 2</a>
<a class="list-group-item list-group-item-action" href="#list-item-3">Item 3</a>
</div>
</div>
<div class="col-8">
<twig:Scrollspy target="#list-scrollspy" height="240px" class="p-2">
{% for number in 1..3 %}
<h4 id="list-item-{{ number }}">Item {{ number }}</h4>
<p>List group links work as Scrollspy navigation controls.</p>
<div style="min-height: 125px"></div>
{% endfor %}
</twig:Scrollspy>
</div>
</div>
Simple anchors
Track sections with lightweight anchor navigation and optional smooth scrolling.
<div class="w-100">
<nav id="simple-scrollspy" class="hstack gap-3 mb-3">
<a class="p-2 link-offset-2 link-underline link-underline-opacity-0" href="#simple-first">First</a>
<a class="p-2 link-offset-2 link-underline link-underline-opacity-0" href="#simple-second">Second</a>
<a class="p-2 link-offset-2 link-underline link-underline-opacity-0" href="#simple-third">Third</a>
</nav>
<twig:Scrollspy target="#simple-scrollspy" smoothScroll height="240px" class="border rounded p-3">
{% for id, title in {first: 'First section', second: 'Second section', third: 'Third section'} %}
<h4 id="simple-{{ id }}">{{ title }}</h4>
<p>Simple links receive the active class when this section becomes current.</p>
<div style="min-height: 130px"></div>
{% endfor %}
</twig:Scrollspy>
</div>
API Reference
<twig:Scrollspy>
| Prop | Type | Default |
|---|---|---|
target The CSS selector of the navigation element controlled by Scrollspy.
|
string |
- |
height The height of the scrollable region.
|
string |
'200px' |
smoothScroll Whether link activation scrolls smoothly to its target section.
|
boolean |
false |
rootMargin The optional Intersection Observer root margin.
|
string|null |
null |
threshold The optional comma-separated Intersection Observer thresholds.
|
string|null |
null |
| Block | Description |
|---|---|
content |
The sections observed by Scrollspy. |