Skip to main content
Components
Scrollspy
pnpm add @winkintel/bootstrap-svelte
GitHub

Scrollspy

An attachment-based scrollspy component that automatically updates navigation or list group components based on scroll position to indicate which section of the page is currently active in the viewport.


Scrollspy is implemented as a Svelte attachment using the {@attach ...} directive. It works by:

  • Using the IntersectionObserver API to monitor when elements scroll into view
  • Calling a callback function when elements intersect with the scrolling container
  • Updating the active state of navigation elements based on the currently visible content

To use Scrollspy, you need:

  • A scrollable container element with the {@attach scrollspy(...)} directive
  • A set of navigation elements (such as Nav.Link, ListGroup.Item, or simple anchors)
  • Since we are using Svelte, it is not necessary to have content elements with IDs that match the href attributes of the navigation elements. However, it is a good practice to ensure that the IDs are unique and correspond to the navigation links.
  • A callback function to update a Svelte state variable to control the active state of navigation elements.

Scrollspy also works with nested Nav components. When a nested nav item is active, its parent will also be active.

            
            <script lang="ts">
import { Nav, scrollspy } from '$lib/index.js';

// State to track which link should be active
let activeNestedNavId: string | null = $state(null);

// Callback function for intersection events
const nestedNavCallback: IntersectionObserverCallback = (entries: IntersectionObserverEntry[]) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            activeNestedNavId = entry.target.id;
        }
    });
};
</script>

<div class="row">
    <div class="col-4">
        <Nav.Root id="navbar-example3" verticalBreakpoint="xs" itemStyle="pills">
            <Nav.Link href="#item-1" isActive={activeNestedNavId?.includes('item-1')}>Item 1</Nav.Link>
            <Nav.Root class="ms-3" verticalBreakpoint="xs">
                <Nav.Link href="#item-1-1" isActive={activeNestedNavId === 'item-1-1'}>Item 1-1</Nav.Link>
                <Nav.Link href="#item-1-2" isActive={activeNestedNavId === 'item-1-2'}>Item 1-2</Nav.Link>
            </Nav.Root>
            <Nav.Link href="#item-2" isActive={activeNestedNavId === 'item-2'}>Item 2</Nav.Link>
            <Nav.Link href="#item-3" isActive={activeNestedNavId?.includes('item-3')}>Item 3</Nav.Link>
            <Nav.Root class="ms-3" verticalBreakpoint="xs">
                <Nav.Link href="#item-3-1" isActive={activeNestedNavId === 'item-3-1'}>Item 3-1</Nav.Link>
                <Nav.Link href="#item-3-2" isActive={activeNestedNavId === 'item-3-2'}>Item 3-2</Nav.Link>
            </Nav.Root>
        </Nav.Root>
    </div>
    <div class="col-8">
        <div
            {@attach scrollspy({
                targetSelector: '#navbar-example3',
                callback: nestedNavCallback,
                observerOptions: { threshold: 0.5 }
            })}
            class="scrollspy-example-2"
            tabindex="0"
            role="presentation">
            <!-- Content with IDs matching the Nav.Link href values -->
            <div id="item-1"><h4>Item 1</h4><p>Content for item 1...</p></div>
            <div id="item-1-1"><h5>Item 1-1</h5><p>Content for item 1-1...</p></div>
        </div>
    </div>
</div>
        

Scrollspy works seamlessly with Bootstrap list groups. This example shows how to use Scrollspy with a ListGroup component.

            
            <script lang="ts">
import { ListGroup, scrollspy } from '$lib/index.js';

// State to track which list group item should be active
let activeListGroupItemId: string | null = $state(null);

// Callback function for intersection events
const listGroupCallback: IntersectionObserverCallback = (entries: IntersectionObserverEntry[]) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            activeListGroupItemId = entry.target.id;
        }
    });
};
</script>

<div class="row">
    <div class="col-4">
        <ListGroup.Root id="list-example">
            <ListGroup.Item
                href="#list-item-1"
                isAction
                isActive={activeListGroupItemId === 'list-item-1'}>
                Item 1
            </ListGroup.Item>
            <ListGroup.Item
                href="#list-item-2"
                isAction
                isActive={activeListGroupItemId === 'list-item-2'}>
                Item 2
            </ListGroup.Item>
        </ListGroup.Root>
    </div>
    <div class="col-8">
        <div
            {@attach scrollspy({
                targetSelector: '#list-example',
                callback: listGroupCallback,
                observerOptions: { threshold: 0.5 }
            })}
            class="scrollspy-example"
            tabindex="0"
            role="presentation">
            <h4 id="list-item-1">Item 1</h4>
            <p>Content for item 1...</p>
            <h4 id="list-item-2">Item 2</h4>
            <p>Content for item 2...</p>
        </div>
    </div>
</div>
        

Scrollspy isn't limited to Nav components and list groups - it works with any anchor elements in the document. This example shows how to use Scrollspy with simple anchors.

            
            <script lang="ts">
import { scrollspy } from '$lib/index.js';

// State to track which anchor should be active
let activeSimpleAnchorItemId: string | null = $state(null);

// Callback function for intersection events
const simpleAnchorsCallback: IntersectionObserverCallback = (entries: IntersectionObserverEntry[]) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            activeSimpleAnchorItemId = entry.target.id;
        }
    });
};
</script>

<div class="row">
    <div class="col-4">
        <div class="d-flex flex-column gap-2 text-center" id="simple-items">
            <a
                class="p-1 rounded"
                class:active={activeSimpleAnchorItemId === 'simple-item-1'}
                href="#simple-item-1">
                Item 1
            </a>
            <a
                class="p-1 rounded"
                class:active={activeSimpleAnchorItemId === 'simple-item-2'}
                href="#simple-item-2">
                Item 2
            </a>
        </div>
    </div>
    <div class="col-8">
        <div
            {@attach scrollspy({
                targetSelector: '#simple-items',
                callback: simpleAnchorsCallback,
                observerOptions: { offset: 0, threshold: 0.5 }
            })}
            class="scrollspy-example"
            tabindex="0"
            role="presentation">
            <h4 id="simple-item-1">Item 1</h4>
            <p>Content for item 1...</p>
            <h4 id="simple-item-2">Item 2</h4>
            <p>Content for item 2...</p>
        </div>
    </div>
</div>
        

Scrollspy Attachment

The scrollspy function creates an attachment that can be applied to an element with the {@attach ...} directive.

            
            // Type definition for ScrollspyOptions
export type ScrollspyOptions = {
    targetSelector: string;
    callback: IntersectionObserverCallback;
    observerOptions?: IntersectionObserverInit;
};

// Usage example
<div
    {@attach scrollspy({
        targetSelector: '#element-id',     // A CSS selector to the element that contains the anchor targets.
        callback: myCallback,              // Function to call when elements intersect
        observerOptions: {                 // Standard IntersectionObserver options
            rootMargin: '0px 0px -40%',    // Margins around the root
            threshold: 0.5                 // How much of target must be visible to trigger
        }
    })}
    class="scrollspy-container"
    tabindex="0">
    <!-- Content with elements matching targetSelector -->
</div>
        

Options

OptionTypeDefaultDescription
targetSelectorstringRequiredCSS selector for elements to observe within the scrolling container
callbackIntersectionObserverCallbackRequiredFunction called when elements intersect with the container
observerOptionsIntersectionObserverInit{
root:[scrollable container element],
rootMargin:'0px 0px -25%',
threshold:[0.1, 0.5, 1]
}
Configuration options for the IntersectionObserver. Can include rootMargin, threshold, etc. See MDN Documentation

Usage Notes

  • The scrollable container must have overflow: auto or overflow: scroll CSS applied
  • For accessibility, give the scrollable container a tabindex="0" and a role="presentation"
  • Each target element must have an ID that corresponds to the href attributes in your navigation
  • The callback should update state variables that control the isActive prop on navigation components