Scroll-Spy Navigation: Highlighting the Active Section with Alpine.js
AI generated
x-data
Alpine
Alpine.js / Practical Case Study
Scroll-Spy Navigation: Highlighting the Active Section
using the Intersection Observer API to detect which section is currently visible

A table of contents that automatically highlights the link to the section currently being read is now standard practice on longer articles and documentation pages. In the past, this behavior was often implemented with a custom scroll event listener and manual position calculations, which noticeably stuttered on many devices, since scroll events fire very frequently and every single calculation loads the main thread. With the Intersection Observer API and Alpine's x-intersect plugin, the same effect can be achieved much more efficiently, because the browser handles the visibility check itself instead of repeating it manually in JavaScript on every scroll event.

10 min read Intersection Observer instead of scroll events x-intersect plugin Table of contents with link highlighting

1. Why scroll events are the wrong choice for this task

A naive scroll-spy approach registers a scroll event listener on the window and, on every call, calculates whether a given section is currently within the visible area, usually via getBoundingClientRect() for every single section. The problem is the frequency: a scroll event can fire several hundred times per second during smooth scrolling, and each of these triggers forces a so-called layout reflow with getBoundingClientRect(), during which the browser has to recalculate the current position of all relevant elements.

On a page with ten or more sections, this quickly adds up to noticeable stuttering, especially on mobile devices with less computing power. The Intersection Observer API solves this problem fundamentally differently: instead of actively asking whether an element is visible, the application merely registers a callback that the browser itself calls exactly when the visibility status of an observed element actually changes, typically asynchronously and outside the critical rendering path.

2. Basics of x-intersect in Alpine.js

Alpine.js ships the x-intersect directive through its Intersect plugin, which internally wraps the Intersection Observer API and runs a declarative expression as soon as the element enters the visible area. Combined with the .leave modifier, an expression can additionally run as soon as the element leaves the visible area again, which is essential for a scroll spy, since the active state needs to be both set and removed on every section transition.

Important for precise scroll-spy detection is the .margin modifier, which adjusts the root margin of the underlying observer. Without adjustment, a section already counts as visible the moment even a single pixel appears in the viewport, which for very tall sections leads to several sections being marked active at once. A negative margin on the top and bottom edges narrows the effective detection area down to a thin strip around the vertical middle of the viewport.


function scrollSpy() {
    return {
        activeSection: null,
        setActive(id) {
            this.activeSection = id;
        },
    };
}

3. Practical example: table of contents with active link highlighting

In the markup, every section gets an x-intersect attribute that sets activeSection to its own ID upon entering the observed area. The matching link in the table of contents then compares, via a :class binding, whether its own ID matches activeSection, and gets a highlighted look when it matches, for instance a different text color or a left border.

To ensure the detection really only considers a narrow, centered strip of the viewport, the margin modifier gets set so that a large portion of the viewport is excluded both above and below. This means a section only counts as active once its start has reached roughly the upper half of the visible area, which matches the intuitive expectation of which section is actually being read at that moment.


<nav x-data="scrollSpy()" class="sticky top-4">
    <a
        href="#introduction"
        :class="activeSection === 'introduction' ? 'text-teal-700 font-semibold' : 'text-gray-500'"
    >Introduction</a>
    <a
        href="#main-part"
        :class="activeSection === 'main-part' ? 'text-teal-700 font-semibold' : 'text-gray-500'"
    >Main Part</a>
    <a
        href="#conclusion"
        :class="activeSection === 'conclusion' ? 'text-teal-700 font-semibold' : 'text-gray-500'"
    >Conclusion</a>
</nav>

<article x-data="scrollSpy()">
    <section
        id="introduction"
        x-intersect.margin.-40%.0.-40%.0="setActive('introduction')"
    >...</section>
    <section
        id="main-part"
        x-intersect.margin.-40%.0.-40%.0="setActive('main-part')"
    >...</section>
    <section
        id="conclusion"
        x-intersect.margin.-40%.0.-40%.0="setActive('conclusion')"
    >...</section>
</article>

4. Sharing state between navigation and content with Alpine.store

In the previous example, navigation and content live in separate x-data instances, each holding its own, independent activeSection state, which effectively means a change in the content never reaches the navigation. For a genuine scroll spy, both areas need to share the same state, which is exactly what Alpine.store is built for, since a store gets registered globally and can be read and written by any number of components at once.

Registration happens once, usually inside an alpine:init listener, and both the navigation and the observed sections subsequently access the same shared value through $store.scrollSpy.activeSection. This eliminates the need to synchronize state between independent components via custom events and keeps the logic in a single, central place.


document.addEventListener('alpine:init', () => {
    Alpine.store('scrollSpy', {
        activeSection: null,
        setActive(id) {
            this.activeSection = id;
        },
    });
});

5. The performance difference to scroll-event-based approaches in detail

The central difference lies in where the visibility calculation happens: with a scroll-event approach, it runs synchronously on the JavaScript main thread on every single event, while the Intersection Observer API leaves the actual geometry calculation to the browser itself, which uses optimized, often asynchronous internal mechanisms that don't necessarily have to be synchronized with every single frame. That noticeably relieves the main thread, especially on pages with many observed sections.

In practice, the difference shows up most clearly during fast scrolling with a mouse wheel or a trackpad gesture on longer pages: a scroll-event approach without debouncing or throttling can cause noticeable stutter here, while an Intersection-Observer-based solution stays consistently smooth, since it activates only on actual visibility changes, independent of scroll frequency.

6. Using multiple thresholds for finer-grained visibility levels

By default, x-intersect triggers as soon as the first pixel becomes visible, which is usually sufficient for a simple scroll spy. For more fine-grained requirements, such as a progress indicator that maps a section's degree of visibility across several levels, the underlying observer can be configured with multiple thresholds, so the callback fires again at 25, 50, 75, and 100 percent visibility.

For the classic use case of the active section in a table of contents, however, a single, well-chosen margin range is entirely sufficient, since only a distinction between visible and not visible is needed here, not between different degrees of visibility.

7. Integration with smooth scroll when clicking a navigation link

An often overlooked detail arises when users click a link in the table of contents directly: during the programmatically triggered, animated scroll motion, the view briefly passes through all sections in between, causing the Intersection Observer to briefly set activeSection for each of these sections before the target section is finally reached. That produces a brief, visible flicker of the highlight in the table of contents during the scroll animation.

A robust solution is briefly locking the automatic update for the duration of the programmatic scroll animation, controlled through a simple flag that gets set on a link click and reset again once the animation finishes, for instance via a scrollend event or a fixed timeout. While the flag is active, activeSection instead gets set immediately to the actually clicked target ID.


navigateTo(id) {
    this.isProgrammaticScroll = true;
    this.activeSection = id;
    document.getElementById(id).scrollIntoView({ behavior: 'smooth' });
    document.addEventListener('scrollend', () => {
        this.isProgrammaticScroll = false;
    }, { once: true });
},
setActive(id) {
    if (this.isProgrammaticScroll) return;
    this.activeSection = id;
}

8. Browser support and fallback behavior

The Intersection Observer API has been fully supported by all current browsers for several years, so an explicit fallback for modern projects is usually unnecessary in practice. For projects that still need to support very old browser versions, feature detection via 'IntersectionObserver' in window is a good approach, falling back to a simpler but less performant scroll-event solution when support is missing.

In practice, this extra effort only pays off for a very small, clearly defined set of target audiences with a known, outdated browser landscape. For the vast majority of projects, especially in a Hyvä context with modern target browsers, x-intersect can be used directly without any additional fallback logic.

9. Limits of this approach with highly dynamic layouts

If a section's height changes dynamically after the initial render, for instance through images loaded afterward without reserved space, or through expandable subsections, the position of the margin boundaries relative to the actual section content can shift without the observer automatically recalibrating. In most cases, the Intersection Observer API still reliably notices such layout shifts, since it continuously reacts to actual position changes rather than a one-time calculated snapshot.

On very complex, highly dynamic pages with frequent layout jumps, it is still worth equipping critical images and embedded content with explicit width and height attributes or reserved space via CSS, to avoid layout shifts in the first place. That not only improves the precision of the scroll spy, but also the overall perception of the page as stable, and is an established best practice for Core Web Vitals anyway.

Aspect scroll-event approach Intersection Observer / x-intersect Practical relevance
Trigger On every scroll event, very frequent Only on an actual visibility change Noticeably fewer calculations
Layout reflow getBoundingClientRect() forces a reflow Geometry calculation optimized by the browser Smoother scrolling
Implementation Manual position calculation required Declarative via the x-intersect attribute Less custom code
Shared state Custom solution required Alpine.store for global state Navigation and content stay in sync
Browser support Universal In all current browsers for years No fallback needed in practice

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Scroll-Spy Navigation with Alpine.js: The Essentials at a Glance

Core problem

Scroll-event-based visibility checks with getBoundingClientRect() force frequent, expensive layout reflows during fast scrolling.

Solution with x-intersect

The Intersection Observer API handles the visibility check on the browser side and triggers only on an actual status change.

Shared state

Alpine.store holds activeSection centrally, so navigation and observed sections use the same state.

Practical fine-tuning

A margin range prevents multiple matches, a lock flag prevents flicker during programmatic scroll animations.

11. FAQ: Scroll-Spy Navigation with Alpine.js: The Essentials at a Glance

1Why is a scroll event listener problematic for scroll spy?
Scroll events fire very frequently, and getBoundingClientRect() forces a layout reflow every single time, which causes noticeable stutter with many sections.
2What does the Intersection Observer API do differently?
It only triggers the callback when the visibility status of an observed element actually changes, instead of actively checking on every scroll event.
3What does the .margin modifier do in x-intersect?
It adjusts the observer's root margin and narrows the effective detection area down to a smaller strip within the viewport, avoiding multiple matches.
4Why isn't a single x-data instance enough for navigation and content?
Separate x-data instances each hold their own, independent state, so changes in the content never reach the navigation.
5How does Alpine.store solve the shared state problem?
A store registered once is globally available and can be read and written by any number of components at the same time.
6Why does the highlight sometimes flicker when clicking a navigation link?
During the animated scroll motion, the view briefly passes through all sections in between, each of which the observer briefly marks as active.
7How is this flicker prevented?
A flag locks the automatic update during the programmatic scroll animation, activeSection instead gets set immediately to the target ID.
8Does x-intersect need a fallback for older browsers?
Usually not in practice, since the Intersection Observer API has been fully supported by all current browsers for several years.
9What happens with dynamically growing sections, for instance due to lazily loaded images?
The Intersection Observer API continuously reacts to actual position changes, reserved space via CSS still additionally improves precision.
10When do multiple thresholds make more sense than a single margin range?
For fine-grained requirements like a progress indicator that maps a section's degree of visibility across several levels.