I’ll build this as a strong standalone reading-progress module for RX Theme: top progress bar, circular indicator, section progress, scroll direction classes, CSS injection, accessibility, performance-safe throttling, and WordPress-friendly auto-init.
Yes friend. Create this file:
assets/static-js/static-chunks-js/chunk-007-reading-progress.js
Then paste this full advanced code.
/*!
* RX Theme - Reading Progress Advanced Module
* File: assets/static-js/static-chunks-js/chunk-007-reading-progress.js
* Version: 1.0.0
*
* Features:
* - Top reading progress bar
* - Optional bottom progress bar
* - Circular reading progress indicator
* - Percent text indicator
* - Article/content-aware progress calculation
* - Estimated reading time
* - Remaining reading time
* - Scroll direction body classes
* - Reading-started / reading-completed classes
* - Milestone events: 25%, 50%, 75%, 100%
* - Lightweight CSS auto-injection
* - MutationObserver support for dynamic content
* - ResizeObserver support
* - requestAnimationFrame performance optimization
* - Accessibility support
*/
(function () {
'use strict';
const RXReadingProgress = {
config: {
enabled: true,
selectors: {
content: [
'article',
'.entry-content',
'.post-content',
'.single-content',
'.rx-content',
'.rx-article-content',
'.site-main',
'main'
],
exclude: [
'.rx-no-reading-progress',
'[data-rx-no-reading-progress]'
]
},
progressBar: {
enabled: true,
position: 'top', // top or bottom
height: 4,
zIndex: 99999,
className: 'rx-reading-progress-bar'
},
circularProgress: {
enabled: true,
showOnDesktop: true,
showOnMobile: true,
mobileBreakpoint: 768,
position: 'right-bottom',
size: 54,
strokeWidth: 5,
className: 'rx-reading-progress-circle',
showPercentText: true
},
readingTime: {
enabled: true,
wordsPerMinute: 220,
showEstimatedTime: true,
showRemainingTime: true,
className: 'rx-reading-time-box'
},
behavior: {
hideBeforeScroll: false,
hideAfterComplete: false,
smoothBar: true,
addBodyClasses: true,
emitCustomEvents: true,
updateUrlHash: false
},
milestones: [25, 50, 75, 100],
classes: {
bodyEnabled: 'rx-reading-progress-enabled',
bodyStarted: 'rx-reading-started',
bodyCompleted: 'rx-reading-completed',
scrollingUp: 'rx-scrolling-up',
scrollingDown: 'rx-scrolling-down',
contentActive: 'rx-reading-content-active'
},
colors: {
barBackground: 'var(--rx-reading-progress-bg, linear-gradient(90deg, #0ea5e9, #22c55e))',
barTrack: 'var(--rx-reading-progress-track, transparent)',
circleTrack: 'var(--rx-reading-circle-track, rgba(15, 23, 42, 0.15))',
circleProgress: 'var(--rx-reading-circle-progress, #0ea5e9)',
circleText: 'var(--rx-reading-circle-text, #0f172a)',
timeBoxBackground: 'var(--rx-reading-time-bg, rgba(255,255,255,0.92))',
timeBoxText: 'var(--rx-reading-time-text, #0f172a)'
},
storage: {
enabled: true,
keyPrefix: 'rx_reading_progress_'
},
debug: false
},
state: {
initialized: false,
contentElement: null,
progressBar: null,
circleWrap: null,
circleProgress: null,
circleText: null,
readingTimeBox: null,
ticking: false,
lastScrollY: 0,
progress: 0,
lastProgress: 0,
started: false,
completed: false,
firedMilestones: {},
estimatedMinutes: 0,
remainingMinutes: 0,
resizeObserver: null,
mutationObserver: null
},
init(customConfig) {
if (this.state.initialized) return;
this.config = this.deepMerge(this.config, customConfig || {});
if (!this.config.enabled) return;
if (this.isExcludedPage()) return;
this.state.contentElement = this.findContentElement();
if (!this.state.contentElement) return;
this.injectStyles();
this.createProgressBar();
this.createCircularProgress();
this.createReadingTimeBox();
this.calculateReadingTime();
this.bindEvents();
this.observeContentChanges();
this.restoreProgress();
document.body.classList.add(this.config.classes.bodyEnabled);
this.state.contentElement.classList.add(this.config.classes.contentActive);
this.update();
this.state.initialized = true;
this.log('RX Reading Progress initialized');
},
deepMerge(target, source) {
const output = Object.assign({}, target);
if (!source || typeof source !== 'object') {
return output;
}
Object.keys(source).forEach((key) => {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
output[key] = this.deepMerge(target[key] || {}, source[key]);
} else {
output[key] = source[key];
}
});
return output;
},
isExcludedPage() {
return this.config.selectors.exclude.some((selector) => {
return document.querySelector(selector);
});
},
findContentElement() {
for (let i = 0; i < this.config.selectors.content.length; i++) {
const selector = this.config.selectors.content[i];
const element = document.querySelector(selector);
if (element && this.getTextWordCount(element) > 80) {
return element;
}
}
return document.body;
},
createProgressBar() {
if (!this.config.progressBar.enabled) return;
const bar = document.createElement('div');
const inner = document.createElement('div');
bar.className = `${this.config.progressBar.className} ${this.config.progressBar.className}--${this.config.progressBar.position}`;
bar.setAttribute('aria-hidden', 'true');
inner.className = `${this.config.progressBar.className}__inner`;
bar.appendChild(inner);
document.body.appendChild(bar);
this.state.progressBar = inner;
},
createCircularProgress() {
if (!this.config.circularProgress.enabled) return;
const isMobile = window.innerWidth <= this.config.circularProgress.mobileBreakpoint;
if (isMobile && !this.config.circularProgress.showOnMobile) return;
if (!isMobile && !this.config.circularProgress.showOnDesktop) return;
const size = this.config.circularProgress.size;
const strokeWidth = this.config.circularProgress.strokeWidth;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const wrap = document.createElement('button');
wrap.type = 'button';
wrap.className = `${this.config.circularProgress.className} ${this.config.circularProgress.className}--${this.config.circularProgress.position}`;
wrap.setAttribute('aria-label', 'Reading progress. Click to go to top.');
wrap.setAttribute('title', 'Reading progress - click to go to top');
wrap.innerHTML = `
<svg class="${this.config.circularProgress.className}__svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">
<circle
class="${this.config.circularProgress.className}__track"
cx="${size / 2}"
cy="${size / 2}"
r="${radius}"
stroke-width="${strokeWidth}"
fill="none"
></circle>
<circle
class="${this.config.circularProgress.className}__progress"
cx="${size / 2}"
cy="${size / 2}"
r="${radius}"
stroke-width="${strokeWidth}"
fill="none"
stroke-linecap="round"
stroke-dasharray="${circumference}"
stroke-dashoffset="${circumference}"
></circle>
</svg>
${
this.config.circularProgress.showPercentText
? `<span class="${this.config.circularProgress.className}__text">0%</span>`
: ''
}
`;
wrap.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
document.body.appendChild(wrap);
this.state.circleWrap = wrap;
this.state.circleProgress = wrap.querySelector(`.${this.config.circularProgress.className}__progress`);
this.state.circleText = wrap.querySelector(`.${this.config.circularProgress.className}__text`);
},
createReadingTimeBox() {
if (!this.config.readingTime.enabled) return;
const box = document.createElement('div');
box.className = this.config.readingTime.className;
box.setAttribute('aria-live', 'polite');
box.innerHTML = `
<span class="${this.config.readingTime.className}__estimated"></span>
<span class="${this.config.readingTime.className}__remaining"></span>
`;
const target = this.state.contentElement;
if (target && target.parentNode) {
target.parentNode.insertBefore(box, target);
} else {
document.body.appendChild(box);
}
this.state.readingTimeBox = box;
},
calculateReadingTime() {
const words = this.getTextWordCount(this.state.contentElement);
const minutes = Math.max(1, Math.ceil(words / this.config.readingTime.wordsPerMinute));
this.state.estimatedMinutes = minutes;
this.state.remainingMinutes = minutes;
this.updateReadingTimeText();
},
getTextWordCount(element) {
if (!element) return 0;
const clone = element.cloneNode(true);
clone.querySelectorAll('script, style, noscript, iframe, svg, canvas, form').forEach((node) => {
node.remove();
});
const text = clone.textContent || '';
const cleanText = text.trim().replace(/\s+/g, ' ');
if (!cleanText) return 0;
return cleanText.split(' ').length;
},
getProgressData() {
const element = this.state.contentElement;
if (!element) {
return {
progress: 0,
start: 0,
end: 0,
scrollY: window.scrollY || window.pageYOffset
};
}
const rect = element.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const contentTop = rect.top + scrollY;
const contentHeight = element.offsetHeight;
const contentBottom = contentTop + contentHeight;
const start = Math.max(0, contentTop - viewportHeight * 0.15);
const end = Math.max(start + 1, contentBottom - viewportHeight * 0.85);
const rawProgress = ((scrollY - start) / (end - start)) * 100;
const progress = Math.min(100, Math.max(0, rawProgress));
return {
progress,
start,
end,
scrollY,
contentTop,
contentBottom,
viewportHeight,
contentHeight
};
},
update() {
const data = this.getProgressData();
const progress = data.progress;
this.state.lastProgress = this.state.progress;
this.state.progress = progress;
this.updateProgressBar(progress);
this.updateCircularProgress(progress);
this.updateReadingState(progress);
this.updateScrollDirection(data.scrollY);
this.updateRemainingTime(progress);
this.handleMilestones(progress);
this.storeProgress(progress);
this.state.ticking = false;
},
requestUpdate() {
if (this.state.ticking) return;
this.state.ticking = true;
window.requestAnimationFrame(() => {
this.update();
});
},
updateProgressBar(progress) {
if (!this.state.progressBar) return;
const value = `${progress.toFixed(2)}%`;
if (this.config.behavior.smoothBar) {
this.state.progressBar.style.transform = `scaleX(${progress / 100})`;
} else {
this.state.progressBar.style.width = value;
}
this.state.progressBar.setAttribute('data-progress', Math.round(progress));
},
updateCircularProgress(progress) {
if (!this.state.circleProgress) return;
const radius = this.state.circleProgress.r.baseVal.value;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (progress / 100) * circumference;
this.state.circleProgress.style.strokeDashoffset = offset;
if (this.state.circleText) {
this.state.circleText.textContent = `${Math.round(progress)}%`;
}
if (this.state.circleWrap) {
this.state.circleWrap.setAttribute('aria-label', `Reading progress ${Math.round(progress)} percent. Click to go to top.`);
}
},
updateReadingState(progress) {
if (!this.config.behavior.addBodyClasses) return;
if (progress > 2 && !this.state.started) {
this.state.started = true;
document.body.classList.add(this.config.classes.bodyStarted);
this.emit('rx:reading-started', { progress });
}
if (progress >= 98 && !this.state.completed) {
this.state.completed = true;
document.body.classList.add(this.config.classes.bodyCompleted);
this.emit('rx:reading-completed', { progress: 100 });
if (this.config.behavior.hideAfterComplete) {
this.hideIndicators();
}
}
if (progress < 95 && this.state.completed) {
this.state.completed = false;
document.body.classList.remove(this.config.classes.bodyCompleted);
}
},
updateScrollDirection(currentScrollY) {
if (!this.config.behavior.addBodyClasses) return;
const lastScrollY = this.state.lastScrollY;
if (currentScrollY > lastScrollY + 5) {
document.body.classList.add(this.config.classes.scrollingDown);
document.body.classList.remove(this.config.classes.scrollingUp);
}
if (currentScrollY < lastScrollY - 5) {
document.body.classList.add(this.config.classes.scrollingUp);
document.body.classList.remove(this.config.classes.scrollingDown);
}
this.state.lastScrollY = currentScrollY;
},
updateRemainingTime(progress) {
const remainingRatio = Math.max(0, (100 - progress) / 100);
this.state.remainingMinutes = Math.max(0, Math.ceil(this.state.estimatedMinutes * remainingRatio));
this.updateReadingTimeText();
},
updateReadingTimeText() {
if (!this.state.readingTimeBox) return;
const estimated = this.state.readingTimeBox.querySelector(`.${this.config.readingTime.className}__estimated`);
const remaining = this.state.readingTimeBox.querySelector(`.${this.config.readingTime.className}__remaining`);
if (estimated && this.config.readingTime.showEstimatedTime) {
estimated.textContent = `${this.state.estimatedMinutes} min read`;
}
if (remaining && this.config.readingTime.showRemainingTime) {
const min = this.state.remainingMinutes;
if (min <= 0) {
remaining.textContent = 'Completed';
} else {
remaining.textContent = `${min} min left`;
}
}
},
handleMilestones(progress) {
this.config.milestones.forEach((milestone) => {
if (progress >= milestone && !this.state.firedMilestones[milestone]) {
this.state.firedMilestones[milestone] = true;
this.emit('rx:reading-milestone', {
milestone,
progress
});
document.body.setAttribute('data-rx-reading-milestone', String(milestone));
this.log(`Reading milestone reached: ${milestone}%`);
}
});
},
emit(eventName, detail) {
if (!this.config.behavior.emitCustomEvents) return;
const event = new CustomEvent(eventName, {
bubbles: true,
detail: detail || {}
});
document.dispatchEvent(event);
},
bindEvents() {
window.addEventListener('scroll', this.requestUpdate.bind(this), {
passive: true
});
window.addEventListener('resize', this.debounce(() => {
this.calculateReadingTime();
this.requestUpdate();
}, 150), {
passive: true
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.requestUpdate();
}
});
window.addEventListener('load', () => {
this.calculateReadingTime();
this.requestUpdate();
});
},
observeContentChanges() {
const element = this.state.contentElement;
if (!element) return;
if ('ResizeObserver' in window) {
this.state.resizeObserver = new ResizeObserver(
this.debounce(() => {
this.calculateReadingTime();
this.requestUpdate();
}, 150)
);
this.state.resizeObserver.observe(element);
}
if ('MutationObserver' in window) {
this.state.mutationObserver = new MutationObserver(
this.debounce(() => {
this.calculateReadingTime();
this.requestUpdate();
}, 300)
);
this.state.mutationObserver.observe(element, {
childList: true,
subtree: true,
characterData: true
});
}
},
hideIndicators() {
if (this.state.progressBar && this.state.progressBar.parentNode) {
this.state.progressBar.parentNode.classList.add('rx-reading-hidden');
}
if (this.state.circleWrap) {
this.state.circleWrap.classList.add('rx-reading-hidden');
}
},
showIndicators() {
if (this.state.progressBar && this.state.progressBar.parentNode) {
this.state.progressBar.parentNode.classList.remove('rx-reading-hidden');
}
if (this.state.circleWrap) {
this.state.circleWrap.classList.remove('rx-reading-hidden');
}
},
getStorageKey() {
const path = window.location.pathname || 'home';
return `${this.config.storage.keyPrefix}${path}`;
},
storeProgress(progress) {
if (!this.config.storage.enabled) return;
try {
const data = {
progress: Math.round(progress),
url: window.location.href,
updatedAt: Date.now()
};
localStorage.setItem(this.getStorageKey(), JSON.stringify(data));
} catch (error) {
this.log('Storage failed', error);
}
},
restoreProgress() {
if (!this.config.storage.enabled) return;
try {
const saved = localStorage.getItem(this.getStorageKey());
if (!saved) return;
const data = JSON.parse(saved);
if (!data || typeof data.progress === 'undefined') return;
document.body.setAttribute('data-rx-saved-reading-progress', String(data.progress));
} catch (error) {
this.log('Restore progress failed', error);
}
},
debounce(fn, delay) {
let timer = null;
return function () {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
},
injectStyles() {
if (document.getElementById('rx-reading-progress-style')) return;
const style = document.createElement('style');
style.id = 'rx-reading-progress-style';
const barPosition = this.config.progressBar.position === 'bottom'
? 'bottom: 0; top: auto;'
: 'top: 0; bottom: auto;';
style.textContent = `
.${this.config.progressBar.className} {
position: fixed;
left: 0;
right: 0;
${barPosition}
width: 100%;
height: ${this.config.progressBar.height}px;
background: ${this.config.colors.barTrack};
z-index: ${this.config.progressBar.zIndex};
pointer-events: none;
overflow: hidden;
}
.${this.config.progressBar.className}__inner {
display: block;
width: 100%;
height: 100%;
background: ${this.config.colors.barBackground};
transform: scaleX(0);
transform-origin: left center;
transition: transform 120ms linear;
will-change: transform;
}
.${this.config.circularProgress.className} {
position: fixed;
width: ${this.config.circularProgress.size}px;
height: ${this.config.circularProgress.size}px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.16);
cursor: pointer;
z-index: ${this.config.progressBar.zIndex - 1};
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.${this.config.circularProgress.className}:hover {
transform: translateY(-2px);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22);
}
.${this.config.circularProgress.className}:focus-visible {
outline: 3px solid rgba(14, 165, 233, 0.35);
outline-offset: 3px;
}
.${this.config.circularProgress.className}--right-bottom {
right: 22px;
bottom: 22px;
}
.${this.config.circularProgress.className}--left-bottom {
left: 22px;
bottom: 22px;
}
.${this.config.circularProgress.className}--right-middle {
right: 22px;
top: 50%;
transform: translateY(-50%);
}
.${this.config.circularProgress.className}--left-middle {
left: 22px;
top: 50%;
transform: translateY(-50%);
}
.${this.config.circularProgress.className}__svg {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.${this.config.circularProgress.className}__track {
stroke: ${this.config.colors.circleTrack};
}
.${this.config.circularProgress.className}__progress {
stroke: ${this.config.colors.circleProgress};
transition: stroke-dashoffset 120ms linear;
will-change: stroke-dashoffset;
}
.${this.config.circularProgress.className}__text {
position: relative;
z-index: 2;
font-size: 12px;
font-weight: 700;
line-height: 1;
color: ${this.config.colors.circleText};
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.${this.config.readingTime.className} {
display: inline-flex;
align-items: center;
gap: 10px;
margin: 0 0 18px;
padding: 8px 12px;
border-radius: 999px;
background: ${this.config.colors.timeBoxBackground};
color: ${this.config.colors.timeBoxText};
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.08);
font-size: 14px;
font-weight: 600;
line-height: 1.3;
}
.${this.config.readingTime.className}__estimated::before {
content: "⏱ ";
}
.${this.config.readingTime.className}__remaining::before {
content: "• ";
opacity: 0.65;
}
.rx-reading-hidden {
opacity: 0 !important;
pointer-events: none !important;
transform: translateY(8px);
}
body.rx-reading-started .${this.config.progressBar.className} {
opacity: 1;
}
@media (max-width: ${this.config.circularProgress.mobileBreakpoint}px) {
.${this.config.circularProgress.className} {
width: 48px;
height: 48px;
right: 14px;
bottom: 14px;
}
.${this.config.circularProgress.className}__text {
font-size: 11px;
}
.${this.config.readingTime.className} {
font-size: 13px;
padding: 7px 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.${this.config.progressBar.className}__inner,
.${this.config.circularProgress.className},
.${this.config.circularProgress.className}__progress {
transition: none !important;
}
}
@media print {
.${this.config.progressBar.className},
.${this.config.circularProgress.className},
.${this.config.readingTime.className} {
display: none !important;
}
}
`;
document.head.appendChild(style);
},
destroy() {
window.removeEventListener('scroll', this.requestUpdate);
if (this.state.progressBar && this.state.progressBar.parentNode) {
this.state.progressBar.parentNode.remove();
}
if (this.state.circleWrap) {
this.state.circleWrap.remove();
}
if (this.state.readingTimeBox) {
this.state.readingTimeBox.remove();
}
if (this.state.resizeObserver) {
this.state.resizeObserver.disconnect();
}
if (this.state.mutationObserver) {
this.state.mutationObserver.disconnect();
}
document.body.classList.remove(
this.config.classes.bodyEnabled,
this.config.classes.bodyStarted,
this.config.classes.bodyCompleted,
this.config.classes.scrollingUp,
this.config.classes.scrollingDown
);
this.state.initialized = false;
},
log() {
if (!this.config.debug) return;
console.log.apply(console, ['[RXReadingProgress]', ...arguments]);
}
};
window.RXReadingProgress = RXReadingProgress;
function autoInit() {
const customConfig = window.rxReadingProgressConfig || {};
RXReadingProgress.init(customConfig);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
}
})();
To enqueue this file in your RX Theme, add this in your theme enqueue file, for example:
inc/core/enqueue.php
<?php
/**
* RX Theme Reading Progress JS
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function rx_theme_enqueue_reading_progress_script() {
if ( is_admin() ) {
return;
}
if ( ! is_singular() ) {
return;
}
$theme_version = wp_get_theme()->get( 'Version' );
wp_enqueue_script(
'rx-reading-progress',
get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-007-reading-progress.js',
array(),
$theme_version,
true
);
wp_localize_script(
'rx-reading-progress',
'rxReadingProgressConfig',
array(
'enabled' => true,
'progressBar' => array(
'enabled' => true,
'position' => 'top',
'height' => 4,
),
'circularProgress' => array(
'enabled' => true,
'showOnDesktop' => true,
'showOnMobile' => true,
'position' => 'right-bottom',
'size' => 54,
'strokeWidth' => 5,
'showPercentText' => true,
),
'readingTime' => array(
'enabled' => true,
'wordsPerMinute' => 220,
'showEstimatedTime' => true,
'showRemainingTime' => true,
),
'behavior' => array(
'hideBeforeScroll' => false,
'hideAfterComplete' => false,
'smoothBar' => true,
'addBodyClasses' => true,
'emitCustomEvents' => true,
),
)
);
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_reading_progress_script' );
Best use for RX Theme:
assets/
└── static-js/
└── static-chunks-js/
└── chunk-007-reading-progress.js
This file is safe as a theme chunk because it is independent, auto-initialized, and only loads on single posts/pages when you enqueue it with is_singular().