making reading progress visible, performantly
A scroll progress bar shows readers of a long article at a glance how much content still follows and how far they have already come. With Alpine.js, this progress indicator can be built in a few lines, bound performantly to the scroll event, and shown either as a horizontal bar at the top or vertically on the page edge.
Table of Contents
- 1. Why a scroll progress bar orients readers
- 2. Calculating progress: scrollTop, scrollHeight, clientHeight
- 3. An Alpine component with @scroll.window and x-bind:style
- 4. Performance: requestAnimationFrame instead of every scroll event
- 5. Horizontal bar vs. vertical bar on the page edge
- 6. Combining with section markers and active TOC highlighting
- 7. Styling, aria-hidden, and prefers-reduced-motion
- 8. A reusable Alpine.data component for the entire site
- 9. Implementation approaches compared
- 10. Summary
- 11. FAQ
1. Why a scroll progress bar orients readers
A scroll progress bar answers a simple but important question for readers of a long article: how much text still lies ahead. Without this visual signal, users only have the length of the browser's scrollbar to go by, which is often barely visible on mobile devices and gives no feedback about how long the actual article content is relative to comments, footer, or related articles.
Studies on reading behavior on long content pages repeatedly show that a visible progress indicator reduces the perceived length of an article and lowers the bounce rate, because readers have a concrete goal in view instead of scrolling endlessly. A scroll progress bar is therefore not a purely decorative element, but a measurable UX tool against drop offs on blog articles and documentation pages.
With Alpine.js, such a progress indicator can be built without an extra library. The following sections build a complete scroll progress bar component: from calculating progress, through performant event binding, choosing between horizontal and vertical display, up to combining it with active table of contents highlighting.
2. Calculating progress: scrollTop, scrollHeight, clientHeight
The mathematical core of every scroll progress bar is a simple formula: the current scroll progress in percent is scrollTop divided by the difference between scrollHeight and clientHeight, multiplied by one hundred. scrollTop indicates how many pixels have already been scrolled from the top, scrollHeight is the total height of the scrollable content, and clientHeight is the visible height of the viewport.
A common mistake in this calculation: developers use scrollHeight alone as the denominator, instead of subtracting the visible viewport height. The result is a progress indicator that never reaches one hundred percent, because the last screen of content remains visible even though the user has already reached the end of the page. The correct formula accounts for this difference explicitly and reaches exactly one hundred percent as soon as the user reaches the end of the page.
// Correct scroll progress calculation
function calculateScrollProgress() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
const scrollableDistance = scrollHeight - clientHeight;
if (scrollableDistance <= 0) return 100; // page shorter than viewport
return Math.min(100, (scrollTop / scrollableDistance) * 100);
}
3. An Alpine component with @scroll.window and x-bind:style
Alpine binds scroll events through the .window modifier directly to window, without a manual addEventListener call being needed. The calculated progress lands as a reactive number in the x-data object and is bound as the width or height of the actual progress bar through x-bind:style. This tight coupling between scroll position and CSS property is the core of the entire scroll progress bar component.
It matters to run the calculation not only on the scroll event, but also once during initialization, so a page loaded with an anchor link or browser history already in the middle of the content immediately shows the correct progress, instead of starting at zero and catching up only on the first scroll event.
<div
x-data="scrollProgress"
@scroll.window="progress = calculateProgress()"
class="fixed top-0 left-0 right-0 h-1 z-50"
>
<div
class="h-full bg-teal-500 transition-none"
:style="`width: ${progress}%`"
></div>
</div>
// Alpine.data component for a top-of-page scroll progress bar
document.addEventListener('alpine:init', () => {
Alpine.data('scrollProgress', () => ({
progress: 0,
init() {
// Calculate once on load, not just on the first scroll event
this.progress = this.calculateProgress();
},
calculateProgress() {
const scrollTop = window.scrollY;
const scrollableDistance = document.documentElement.scrollHeight - document.documentElement.clientHeight;
if (scrollableDistance <= 0) return 100;
return Math.min(100, (scrollTop / scrollableDistance) * 100);
}
}));
});
4. Performance: requestAnimationFrame instead of every scroll event
The scroll event fires extremely often in the browser, sometimes several hundred times per second with fast scrolling on a trackpad or mouse wheel. If a recalculation and a DOM update are triggered on every single event, that can cause noticeable stutter on weaker devices, even if the calculation itself is trivial, because every style update potentially forces a repaint.
The standard solution is to throttle the actual update through requestAnimationFrame: the scroll event merely sets a flag that an update is pending, and a single requestAnimationFrame callback performs the actual calculation and style update in sync with the next browser frame. This way, the scroll progress bar never updates more often than the screen can actually redraw, which effectively prevents stutter.
// Throttling scroll progress updates with requestAnimationFrame
Alpine.data('scrollProgress', () => ({
progress: 0,
ticking: false,
onScroll() {
if (!this.ticking) {
requestAnimationFrame(() => {
this.progress = this.calculateProgress();
this.ticking = false;
});
this.ticking = true;
}
},
calculateProgress() {
const scrollTop = window.scrollY;
const scrollableDistance = document.documentElement.scrollHeight - document.documentElement.clientHeight;
if (scrollableDistance <= 0) return 100;
return Math.min(100, (scrollTop / scrollableDistance) * 100);
}
}));
5. Horizontal bar vs. vertical bar on the page edge
The classic variant of the scroll progress bar is a thin, horizontal bar at the top edge of the page that grows across the full width. This variant is simple to implement and barely disturbs the layout, but has the drawback of being visually inconspicuous on wide desktop screens and can easily be mistaken for a loading bar.
The vertical variant on the page edge, usually a narrow strip on the right, instead uses the viewport height as its scale and grows from top to bottom as the user scrolls. This presentation coincides less with loading indicators and combines well with additional elements, for example small markers for individual sections along the same vertical axis. Technically, nothing changes in the calculation, only width gets replaced by height, and the positioning switches from top to right.
<!-- Vertical scroll progress bar on the page edge -->
<div
x-data="scrollProgress"
@scroll.window="onScroll()"
class="fixed top-0 right-0 bottom-0 w-1 z-50 bg-slate-100/40"
>
<div
class="w-full bg-teal-500"
:style="`height: ${progress}%`"
></div>
</div>
6. Combining with section markers and active TOC highlighting
A scroll progress bar unfolds its full usefulness when it shows not only the global progress, but additionally indicates which section of the article the user is currently in. For this, the progress logic can be combined with the Intersection Observer API, which detects which heading is currently in the visible area, and uses that information to visually highlight the corresponding entry in the table of contents.
This combination of a global scroll progress bar and active section highlighting in the table of contents is especially valuable for long technical articles, because readers can see not only how much text remains overall, but also which topical section they are currently in, without having to scroll back up to re read the current heading.
7. Styling, aria-hidden, and prefers-reduced-motion
A scroll progress bar is a purely visual, decorative element without its own interactive function, which is why it should be marked aria-hidden="true" for screen readers. A screen reader user gains nothing from a continuously updated percentage that would be read out on every scroll event, that would be disruptive rather than helpful.
In addition, the width or height change of the bar should not have a CSS transition longer than a few milliseconds, since an overly sluggish animation visibly lags behind the actual progress during fast scrolling. For users with prefers-reduced-motion: reduce, every transition duration should be reduced to zero anyway, since the bar's continuous movement in the corner of the eye can otherwise become noticeable and cause discomfort with vestibular disorders.
/* Respect reduced motion preference for the scroll progress bar */
.scroll-progress-fill {
transition: width 100ms linear;
}
@media (prefers-reduced-motion: reduce) {
.scroll-progress-fill {
transition: none;
}
}
8. A reusable Alpine.data component for the entire site
So the scroll progress bar does not need to be reimplemented on every page, a global registration through Alpine.data is worthwhile, defined once centrally and included on every page via x-data="scrollProgress". In a Hyva theme, this definition can live in a central JavaScript module loaded on all pages through default.xml, instead of repeating the logic in every single template.
A configuration option for the target container makes sense if the progress calculation should apply not to the entire page, but only to a specific article container, for example when header and footer should not factor into the calculation. For that, an element passed as a parameter is referenced instead of document.documentElement, and its own scroll position and height form the basis of the calculation.
9. Implementation approaches compared
There are several technical ways to implement a scroll progress bar, each with different tradeoffs regarding performance and browser support.
| Approach | Trigger | Advantage | Drawback |
|---|---|---|---|
| Direct scroll event | @scroll.window without throttling | Simplest implementation | Can stutter on weaker devices |
| requestAnimationFrame throttling | Scroll event sets a flag, rAF updates | Smooth, tied to frame rate | Slightly more code required |
| CSS scroll-driven animation | animation-timeline: scroll() | No JavaScript, compositor thread | Not yet available in all browsers |
| Intersection Observer for sections | Watching heading elements | Adds section context to the bar | Does not solve the global percentage alone |
For most blog and documentation pages, combining @scroll.window with requestAnimationFrame throttling is the most robust approach, since it works reliably in all current browsers and keeps performance under control without an extra library. Native CSS scroll-driven animations are promising, but not yet available in all relevant browsers currently, which means they are only suitable as a progressive enhancement for now.
Mironsoft
Alpine.js UX components for blogs and long content pages
A scroll progress bar for your long articles?
We build performant scroll progress bars with Alpine.js, including section markers, active TOC highlighting, and full support for prefers-reduced-motion.
Performance check
Reviewing existing scroll handlers for stutter and frame drops
UX components
Scroll progress bar, section markers, and active navigation from one source
Accessibility
aria-hidden and reduced motion planned in from the start
10. Summary
A scroll progress bar built with Alpine.js only needs three values at its core: scrollTop, scrollHeight, and clientHeight, from which progress in percent can be calculated. Binding it to @scroll.window combined with requestAnimationFrame throttling keeps the update performant, even with very fast scrolling on weaker devices.
Whether horizontal bar at the top or vertical bar on the page edge is a design decision, the technical foundation stays identical. Combined with section markers and active table of contents highlighting, the plain progress indicator becomes a full orientation tool for long articles, one that stays accessible too, with correct aria-hidden and consideration for prefers-reduced-motion.
Scroll Progress Bar with Alpine.js — The Essentials at a Glance
Formula
scrollTop divided by (scrollHeight minus clientHeight), times one hundred, capped at one hundred percent.
Performance
requestAnimationFrame throttles updates to the actual refresh rate.
Variants
Horizontal at the top or vertical on the page edge, same calculation, different CSS property.
Accessibility
aria-hidden="true" and no transition when prefers-reduced-motion: reduce is set.