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.
Table of Contents
- 1. Why scroll events are the wrong choice for this task
- 2. Basics of x-intersect in Alpine.js
- 3. Practical example: table of contents with active link highlighting
- 4. Sharing state between navigation and content with Alpine.store
- 5. The performance difference to scroll-event-based approaches in detail
- 6. Using multiple thresholds for finer-grained visibility levels
- 7. Integration with smooth scroll when clicking a navigation link
- 8. Browser support and fallback behavior
- 9. Limits of this approach with highly dynamic layouts
- 10. Summary
- 11. FAQ
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.