
How to Add a Reading Progress Bar in WordPress?
A reading progress bar gives readers a quiet, constant sense of how much of a post is left, and it's one of the simplest UX additions a content site can make. It's a thin strip, usually pinned to the top of the viewport, that fills from left to right as a visitor scrolls down a post, common on Medium, most major news sites, and long-form blogs where readers otherwise have no sense of whether they're 10% or 90% through an article.
It's also a genuinely small feature to build correctly, small enough that a plugin is often more overhead than it's worth, though a couple of solid plugin options exist if you'd rather not touch code at all. This guide covers both, including a complete, working vanilla JavaScript implementation.
How a Reading Progress Bar Actually Works
The mechanism is simple: on every scroll event, calculate what percentage of the page's scrollable height the visitor has scrolled through, then set a bar's width (or, better for performance, its scaleX() transform) to match that percentage. That's the entire feature; everything else is styling and edge-case handling.
Option 1: Use a Plugin
WPFront Scroll Top
WPFront Scroll Top is best known as a "back to top" button plugin, but it includes a reading progress bar as one of its display options, which makes it a reasonable choice if you want both features from a single plugin rather than installing two:
- Install and activate WPFront Scroll Top.
- Go to Settings > WPFront Scroll Top.
- Under the Scroll Progress tab, enable the progress indicator and configure its color, thickness, and position.
- Save changes and check a long post to confirm the bar fills correctly as you scroll.
Reading Progress Bar Plugins
Several smaller, single-purpose plugins in the WordPress.org repository (searching "reading progress bar" surfaces a handful of actively maintained options) do exactly one thing: add a colored bar tied to scroll position, usually with a settings panel for color, height, and which post types it appears on. These are worth considering if you specifically don't want the back-to-top button functionality bundled with WPFront Scroll Top and would rather have a narrowly scoped plugin instead.
Given how small this feature is, it's also one of the clearer cases where writing a few lines of code yourself, rather than installing a plugin, is a completely reasonable choice. If you're deciding between the two in general, this overview of what WordPress plugins are and how they work covers the broader trade-off between plugins and custom code.
Option 2: Build a Custom Reading Progress Bar
Here's a complete, working implementation using nothing but HTML, CSS, and vanilla JavaScript, no jQuery or dependencies required.
Step 1: Add the Bar's Markup
Add this once, right after the opening <body> tag (a lot of themes expose a hook like wp_body_open for exactly this purpose):
add_action( 'wp_body_open', function () {
if ( is_singular( 'post' ) ) {
echo '<div id="tw-reading-progress"><div id="tw-reading-progress-bar"></div></div>';
}
} );
wp_body_open fires immediately after the opening <body> tag on any theme that supports it (all themes since WordPress 5.2 that call wp_body_open() in their header.php), which is the correct place for this kind of persistent UI element, rather than trying to inject it into the_content filter where it would scroll away with the rest of the post.
Step 2: CSS
#tw-reading-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: transparent;
z-index: 9999;
}
#tw-reading-progress-bar {
height: 100%;
width: 100%;
background: #2563eb;
transform: scaleX(0);
transform-origin: left;
will-change: transform;
}
The bar itself is always width: 100%; what changes is its scaleX() transform, not its actual width. This distinction matters for performance, covered in Step 4.
Step 3: JavaScript
(function () {
const bar = document.getElementById("tw-reading-progress-bar");
if (!bar) return;
function updateProgress() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight =
document.documentElement.scrollHeight - window.innerHeight;
// Guard against a division by zero on very short pages that don't scroll at all.
const percent = docHeight > 0 ? scrollTop / docHeight : 0;
bar.style.transform = `scaleX(${Math.min(1, Math.max(0, percent))})`;
}
let ticking = false;
window.addEventListener("scroll", () => {
if (!ticking) {
requestAnimationFrame(() => {
updateProgress();
ticking = false;
});
ticking = true;
}
});
window.addEventListener("resize", updateProgress);
updateProgress(); // Set the correct initial state on page load, e.g. after a mid-page reload.
})();
Enqueue it properly rather than inlining it directly in a template:
add_action( 'wp_enqueue_scripts', function () {
if ( is_singular( 'post' ) ) {
wp_enqueue_script(
'tw-reading-progress',
get_stylesheet_directory_uri() . '/js/reading-progress.js',
[],
'1.0.0',
true
);
}
} );
A few implementation details worth understanding:
transform: scaleX()instead of animatingwidth. Changing an element'swidthon every scroll event forces the browser to recalculate layout (reflow) dozens of times per second, which can visibly stutter on longer pages.transformis handled by the compositor and doesn't trigger layout recalculation, making it dramatically cheaper to animate on every scroll tick.requestAnimationFramecombined with atickingflag throttles the actual DOM update to once per animation frame (typically 60 times a second), even though thescrollevent itself can fire far more often than that. Without this, the handler would run on every single scroll event, doing redundant work between frames the browser hasn't even painted yet.- The
Math.min(1, Math.max(0, percent))clamp prevents the bar from ever exceeding 100% or going negative, which can otherwise happen briefly during elastic "bounce" overscroll on iOS Safari. docHeight > 0 ? ... : 0avoids aNaNresult (and a brokentransform) on pages short enough that there's no scrollable distance at all.
Step 4: Only Load It Where It's Useful
Both the wp_body_open markup and the wp_enqueue_script() call above are already scoped to is_singular( 'post' ), so the bar never loads on the homepage, archive pages, or static pages where "reading progress" through a single piece of content doesn't really apply. This mirrors the same has_shortcode()-style discipline covered in building a custom WordPress shortcode: load assets only where the feature is actually used, not site-wide by default.
A Simpler CSS-Only Variant (No JavaScript)
Modern browsers support driving a progress-style bar purely through CSS scroll-driven animations, without any JavaScript at all:
@supports (animation-timeline: scroll()) {
#tw-reading-progress-bar {
transform-origin: left;
animation: tw-scroll-progress auto linear;
animation-timeline: scroll(root);
}
@keyframes tw-scroll-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
}
This is worth wrapping in an @supports check and pairing with the JavaScript version as a fallback, since animation-timeline support is still catching up across browsers as of this writing; the JavaScript approach in Step 3 works everywhere today without any feature detection needed.
Choosing Between the Two Approaches
- Use WPFront Scroll Top if you also want a "back to top" button and would rather manage both from one settings screen.
- Build it yourself given how small this feature genuinely is; the vanilla JavaScript version above is a dozen lines of real logic, has zero dependencies, and gives you full control over color, thickness, and exactly which post types it appears on.
Frequently Asked Questions (FAQ) About WordPress Reading Progress Bars
Not meaningfully, as long as it's implemented with transform: scaleX() and throttled with requestAnimationFrame, as shown above, rather than animating width directly on every scroll event. A plugin-based version adds slightly more overhead from its settings and admin UI, but the front-end cost is comparable either way.
Nearly all reading progress bars, including every major plugin and the implementation above, use scroll position as a proxy for reading progress, since actual reading speed can't be measured without eye tracking. Scroll-based progress is an approximation, but it's the practical standard and matches what readers already expect from other sites.
Yes, both the markup injection and the script enqueue in the custom implementation are already scoped with is_singular( 'post' ), so it only appears on single blog posts. Change that conditional to is_page() or a custom post type check if you want it somewhere else instead.
This usually means the page has little or no scrollable height beyond the viewport, so scrollTop reaches document.documentElement.scrollHeight - window.innerHeight almost immediately. That's expected behavior for genuinely short content; consider hiding the bar entirely below a minimum word count if this bothers you.
Not for the calculation itself, since it's based on total scrollable distance regardless of what's fixed on screen, but visually you should position the bar so it doesn't overlap or get hidden behind a sticky header. Setting the bar's fixed top value to match your header's height (or placing it above the header in stacking order with a higher z-index) resolves this.
Yes, they solve related but different problems (overall progress versus jumping to a specific section) and commonly appear together on long-form content. See adding a table of contents to WordPress posts for a complementary feature that pairs naturally with a progress bar.
Yes, using the newer animation-timeline: scroll() CSS feature shown above, though browser support is still uneven as of this writing. Pairing it with the JavaScript fallback inside an @supports check is the safest way to use it today without leaving older browsers with no progress bar at all.
Conclusion
A reading progress bar is a small, low-risk addition that gives long-form content a bit more polish and gives readers a persistent sense of how much is left, without asking anything of them. WPFront Scroll Top is a reasonable one-plugin option if you also want a back-to-top button bundled in.
Given how little code this actually takes, though, it's one of the better candidates for skipping a plugin entirely: the vanilla JavaScript version above is a complete, correct implementation using scaleX() and requestAnimationFrame for smooth, low-overhead scroll performance, scoped only to the post types where it's actually useful.
If you do build it yourself, test it on your shortest post and your longest post both, confirm it behaves correctly on a page reload partway down the article, and check that it doesn't visually collide with a sticky header. Those three checks catch the overwhelming majority of real-world edge cases with this feature.


