I’ll build this as a production-ready ScrollSpy chunk for your RX Theme: standalone, safe, configurable, accessible, performance-friendly, and compatible with dynamic medical article pages.
Yes friend. Create this file:
assets/static-js/static-chunks-js/chunk-044-scrollspy.js
Then paste the full code below.
/*!
* RX Theme - Advanced ScrollSpy Chunk
* File: assets/static-js/static-chunks-js/chunk-044-scrollspy.js
* Version: 1.0.0
*
* Features:
* - Automatic heading detection
* - Active table-of-content link highlighting
* - Smooth scrolling with header offset
* - Reading progress support
* - URL hash update
* - Mobile-safe behavior
* - Dynamic content support
* - MutationObserver support
* - Accessibility friendly
* - Performance optimized with IntersectionObserver
*/
(function () {
'use strict';
/**
* Prevent duplicate initialization
*/
if (window.RX_SCROLLSPY_LOADED) {
return;
}
window.RX_SCROLLSPY_LOADED = true;
/**
* Main namespace
*/
window.RXTheme = window.RXTheme || {};
/**
* Default configuration
*/
var RXScrollSpyConfig = {
bodyClass: 'rx-scrollspy-active',
contentSelector:
'.rx-single-content, .entry-content, .post-content, article, main',
headingSelector: 'h2, h3, h4',
tocSelector:
'.rx-toc, .rx-table-of-contents, .rx-sidebar-toc, [data-rx-toc]',
tocLinkSelector: 'a[href^="#"]',
activeClass: 'is-active',
parentActiveClass: 'is-parent-active',
viewedClass: 'is-viewed',
currentSectionClass: 'rx-current-section',
headingIdPrefix: 'rx-heading',
fixedHeaderSelector:
'.site-header, .rx-header, .rx-sticky-header, header[role="banner"]',
adminBarSelector: '#wpadminbar',
scrollOffsetExtra: 16,
rootMarginTop: 30,
rootMarginBottom: 65,
updateHash: true,
smoothScroll: true,
closeMobileTocOnClick: true,
progressBarSelector:
'.rx-reading-progress-bar, [data-rx-reading-progress-bar]',
progressTextSelector:
'.rx-reading-progress-text, [data-rx-reading-progress-text]',
enableMutationObserver: true,
enableKeyboardFocus: true,
enableCustomEvents: true,
debug: false
};
/**
* Utility functions
*/
var RXUtils = {
log: function () {
if (!RXScrollSpyConfig.debug || !window.console) {
return;
}
console.log.apply(console, ['[RX ScrollSpy]'].concat([].slice.call(arguments)));
},
ready: function (callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback, { once: true });
} else {
callback();
}
},
debounce: function (fn, delay) {
var timer = null;
return function () {
var context = this;
var args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
},
throttle: function (fn, limit) {
var waiting = false;
return function () {
var context = this;
var args = arguments;
if (!waiting) {
fn.apply(context, args);
waiting = true;
setTimeout(function () {
waiting = false;
}, limit);
}
};
},
escapeSelector: function (value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(
/([ #;?%&,.+*~':"!^$[\]()=>|/@])/g,
'\\$1'
);
},
slugify: function (text) {
return String(text || '')
.toLowerCase()
.trim()
.replace(/&/g, ' and ')
.replace(/[\s\W-]+/g, '-')
.replace(/^-+|-+$/g, '');
},
getText: function (element) {
if (!element) {
return '';
}
return element.textContent.replace(/\s+/g, ' ').trim();
},
getOffsetHeight: function (selector) {
var el = document.querySelector(selector);
if (!el) {
return 0;
}
var style = window.getComputedStyle(el);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.position === 'static'
) {
return 0;
}
return el.offsetHeight || 0;
},
dispatch: function (name, detail) {
if (!RXScrollSpyConfig.enableCustomEvents) {
return;
}
document.dispatchEvent(
new CustomEvent(name, {
bubbles: true,
detail: detail || {}
})
);
}
};
/**
* RX ScrollSpy object
*/
var RXScrollSpy = {
config: RXScrollSpyConfig,
headings: [],
tocLinks: [],
observer: null,
mutationObserver: null,
currentHeading: null,
initialized: false,
linkMap: new Map(),
/**
* Initialize
*/
init: function (customConfig) {
this.config = Object.assign({}, RXScrollSpyConfig, customConfig || {});
RXScrollSpyConfig = this.config;
this.collectElements();
if (!this.headings.length) {
RXUtils.log('No headings found.');
return;
}
this.prepareHeadings();
this.prepareTocLinks();
this.bindEvents();
this.createObserver();
this.updateProgress();
this.setInitialActiveFromHash();
if (this.config.enableMutationObserver) {
this.observeDomChanges();
}
document.body.classList.add(this.config.bodyClass);
this.initialized = true;
RXUtils.dispatch('rxScrollSpyReady', {
headings: this.headings,
links: this.tocLinks
});
RXUtils.log('Initialized.');
},
/**
* Collect headings and links
*/
collectElements: function () {
var contentAreas = document.querySelectorAll(this.config.contentSelector);
var headingList = [];
contentAreas.forEach(
function (content) {
var headings = content.querySelectorAll(this.config.headingSelector);
headings.forEach(function (heading) {
if (RXUtils.getText(heading).length > 0) {
headingList.push(heading);
}
});
}.bind(this)
);
this.headings = headingList;
var tocAreas = document.querySelectorAll(this.config.tocSelector);
var linkList = [];
tocAreas.forEach(
function (toc) {
var links = toc.querySelectorAll(this.config.tocLinkSelector);
links.forEach(function (link) {
linkList.push(link);
});
}.bind(this)
);
this.tocLinks = linkList;
},
/**
* Generate safe IDs for headings
*/
prepareHeadings: function () {
var usedIds = {};
this.headings.forEach(
function (heading, index) {
var text = RXUtils.getText(heading);
var existingId = heading.getAttribute('id');
var baseId = existingId || RXUtils.slugify(text);
if (!baseId) {
baseId = this.config.headingIdPrefix + '-' + (index + 1);
}
var finalId = baseId;
var counter = 2;
while (usedIds[finalId] || document.querySelectorAll('#' + RXUtils.escapeSelector(finalId)).length > 1) {
finalId = baseId + '-' + counter;
counter++;
}
usedIds[finalId] = true;
heading.setAttribute('id', finalId);
heading.setAttribute('tabindex', '-1');
heading.dataset.rxScrollspyHeading = 'true';
heading.dataset.rxHeadingIndex = String(index);
}.bind(this)
);
},
/**
* Prepare TOC links
*/
prepareTocLinks: function () {
this.linkMap.clear();
this.tocLinks.forEach(
function (link) {
var href = link.getAttribute('href');
if (!href || href === '#') {
return;
}
var id = decodeURIComponent(href.replace('#', ''));
var heading = document.getElementById(id);
if (!heading) {
return;
}
link.dataset.rxScrollspyLink = 'true';
link.setAttribute('aria-current', 'false');
if (!this.linkMap.has(id)) {
this.linkMap.set(id, []);
}
this.linkMap.get(id).push(link);
}.bind(this)
);
},
/**
* Calculate dynamic offset
*/
getScrollOffset: function () {
var headerHeight = RXUtils.getOffsetHeight(this.config.fixedHeaderSelector);
var adminBarHeight = RXUtils.getOffsetHeight(this.config.adminBarSelector);
return headerHeight + adminBarHeight + this.config.scrollOffsetExtra;
},
/**
* Bind scroll, resize, click, keyboard events
*/
bindEvents: function () {
var throttledProgress = RXUtils.throttle(
this.updateProgress.bind(this),
100
);
var debouncedRefresh = RXUtils.debounce(
this.refresh.bind(this),
250
);
window.addEventListener('scroll', throttledProgress, { passive: true });
window.addEventListener('resize', debouncedRefresh, { passive: true });
window.addEventListener('orientationchange', debouncedRefresh, {
passive: true
});
document.addEventListener(
'click',
function (event) {
var link = event.target.closest('a[href^="#"]');
if (!link || !link.dataset.rxScrollspyLink) {
return;
}
this.handleTocClick(event, link);
}.bind(this)
);
if (this.config.enableKeyboardFocus) {
document.addEventListener(
'keydown',
function (event) {
if (event.key === 'Tab') {
document.body.classList.add('rx-keyboard-navigation');
}
},
{ passive: true }
);
document.addEventListener(
'mousedown',
function () {
document.body.classList.remove('rx-keyboard-navigation');
},
{ passive: true }
);
}
},
/**
* Smooth scroll handler
*/
handleTocClick: function (event, link) {
var href = link.getAttribute('href');
if (!href || href === '#') {
return;
}
var id = decodeURIComponent(href.substring(1));
var target = document.getElementById(id);
if (!target) {
return;
}
event.preventDefault();
var offset = this.getScrollOffset();
var targetTop =
target.getBoundingClientRect().top + window.pageYOffset - offset;
if (this.config.smoothScroll) {
window.scrollTo({
top: targetTop,
behavior: 'smooth'
});
} else {
window.scrollTo(0, targetTop);
}
this.setActiveHeading(target);
if (this.config.updateHash && window.history && history.pushState) {
history.pushState(null, '', '#' + encodeURIComponent(id));
}
if (this.config.enableKeyboardFocus) {
target.focus({ preventScroll: true });
}
if (this.config.closeMobileTocOnClick) {
document.body.classList.remove('rx-mobile-toc-open');
}
RXUtils.dispatch('rxScrollSpyLinkClick', {
link: link,
heading: target,
id: id
});
},
/**
* Create IntersectionObserver
*/
createObserver: function () {
if (this.observer) {
this.observer.disconnect();
}
if (!('IntersectionObserver' in window)) {
this.createFallbackScrollSpy();
return;
}
var topMargin =
'-' + (this.getScrollOffset() + this.config.rootMarginTop) + 'px';
var bottomMargin = '-' + this.config.rootMarginBottom + '%';
this.observer = new IntersectionObserver(
function (entries) {
var visibleEntries = entries
.filter(function (entry) {
return entry.isIntersecting;
})
.sort(function (a, b) {
return (
a.target.getBoundingClientRect().top -
b.target.getBoundingClientRect().top
);
});
if (visibleEntries.length) {
this.setActiveHeading(visibleEntries[0].target);
}
}.bind(this),
{
root: null,
rootMargin: topMargin + ' 0px ' + bottomMargin + ' 0px',
threshold: [0, 0.1, 0.25, 0.5, 1]
}
);
this.headings.forEach(
function (heading) {
this.observer.observe(heading);
}.bind(this)
);
},
/**
* Fallback for old browsers
*/
createFallbackScrollSpy: function () {
var fallbackHandler = RXUtils.throttle(
function () {
var scrollPosition = window.pageYOffset + this.getScrollOffset() + 24;
var activeHeading = null;
this.headings.forEach(function (heading) {
if (heading.offsetTop <= scrollPosition) {
activeHeading = heading;
}
});
if (activeHeading) {
this.setActiveHeading(activeHeading);
}
}.bind(this),
100
);
window.addEventListener('scroll', fallbackHandler, { passive: true });
fallbackHandler();
},
/**
* Set active heading and active TOC link
*/
setActiveHeading: function (heading) {
if (!heading || this.currentHeading === heading) {
return;
}
this.currentHeading = heading;
var id = heading.getAttribute('id');
this.headings.forEach(
function (item) {
item.classList.remove(this.config.currentSectionClass);
}.bind(this)
);
heading.classList.add(this.config.currentSectionClass);
heading.classList.add(this.config.viewedClass);
this.clearActiveLinks();
var activeLinks = this.linkMap.get(id) || [];
activeLinks.forEach(
function (link) {
link.classList.add(this.config.activeClass);
link.setAttribute('aria-current', 'true');
var parentLi = link.closest('li');
if (parentLi) {
parentLi.classList.add(this.config.parentActiveClass);
}
var parentDetails = link.closest('details');
if (parentDetails) {
parentDetails.open = true;
}
}.bind(this)
);
if (this.config.updateHash) {
this.updateHashQuietly(id);
}
RXUtils.dispatch('rxScrollSpyChange', {
heading: heading,
id: id,
links: activeLinks
});
},
/**
* Remove old active links
*/
clearActiveLinks: function () {
this.tocLinks.forEach(
function (link) {
link.classList.remove(this.config.activeClass);
link.setAttribute('aria-current', 'false');
var parentLi = link.closest('li');
if (parentLi) {
parentLi.classList.remove(this.config.parentActiveClass);
}
}.bind(this)
);
},
/**
* Quiet hash update without page jump
*/
updateHashQuietly: function (id) {
if (!window.history || !history.replaceState || !id) {
return;
}
var currentHash = decodeURIComponent(window.location.hash.replace('#', ''));
if (currentHash === id) {
return;
}
history.replaceState(null, '', '#' + encodeURIComponent(id));
},
/**
* Initial active heading from URL hash
*/
setInitialActiveFromHash: function () {
if (!window.location.hash) {
if (this.headings[0]) {
this.setActiveHeading(this.headings[0]);
}
return;
}
var id = decodeURIComponent(window.location.hash.replace('#', ''));
var target = document.getElementById(id);
if (!target) {
return;
}
setTimeout(
function () {
var offset = this.getScrollOffset();
var top =
target.getBoundingClientRect().top + window.pageYOffset - offset;
window.scrollTo(0, top);
this.setActiveHeading(target);
}.bind(this),
50
);
},
/**
* Reading progress
*/
updateProgress: function () {
var progressBars = document.querySelectorAll(
this.config.progressBarSelector
);
var progressTexts = document.querySelectorAll(
this.config.progressTextSelector
);
if (!progressBars.length && !progressTexts.length) {
return;
}
var documentHeight =
document.documentElement.scrollHeight - window.innerHeight;
var currentScroll = window.pageYOffset || document.documentElement.scrollTop;
var percent = documentHeight > 0
? Math.min(100, Math.max(0, (currentScroll / documentHeight) * 100))
: 0;
progressBars.forEach(function (bar) {
bar.style.width = percent.toFixed(2) + '%';
bar.setAttribute('aria-valuenow', String(Math.round(percent)));
});
progressTexts.forEach(function (text) {
text.textContent = Math.round(percent) + '%';
});
RXUtils.dispatch('rxReadingProgress', {
percent: percent
});
},
/**
* Refresh after layout/content change
*/
refresh: function () {
this.collectElements();
this.prepareHeadings();
this.prepareTocLinks();
this.createObserver();
this.updateProgress();
RXUtils.dispatch('rxScrollSpyRefresh', {
headings: this.headings,
links: this.tocLinks
});
RXUtils.log('Refreshed.');
},
/**
* Watch dynamic content changes
*/
observeDomChanges: function () {
if (!('MutationObserver' in window)) {
return;
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
}
var debouncedRefresh = RXUtils.debounce(
function () {
this.refresh();
}.bind(this),
400
);
this.mutationObserver = new MutationObserver(function (mutations) {
var shouldRefresh = mutations.some(function (mutation) {
if (mutation.type !== 'childList') {
return false;
}
return [].slice.call(mutation.addedNodes).some(function (node) {
if (!node || node.nodeType !== 1) {
return false;
}
return (
node.matches &&
(
node.matches('h2, h3, h4, .rx-toc, [data-rx-toc]') ||
node.querySelector('h2, h3, h4, .rx-toc, [data-rx-toc]')
)
);
});
});
if (shouldRefresh) {
debouncedRefresh();
}
});
this.mutationObserver.observe(document.body, {
childList: true,
subtree: true
});
},
/**
* Destroy
*/
destroy: function () {
if (this.observer) {
this.observer.disconnect();
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
}
this.clearActiveLinks();
this.headings.forEach(
function (heading) {
heading.classList.remove(
this.config.currentSectionClass,
this.config.viewedClass
);
}.bind(this)
);
document.body.classList.remove(this.config.bodyClass);
this.initialized = false;
RXUtils.dispatch('rxScrollSpyDestroy', {});
}
};
/**
* Expose to global RXTheme namespace
*/
window.RXTheme.ScrollSpy = RXScrollSpy;
/**
* Auto initialization
*/
RXUtils.ready(function () {
window.RXTheme.ScrollSpy.init(window.rxScrollSpyConfig || {});
});
})();
Add this CSS also in your theme CSS for perfect visual effect:
.rx-scrollspy-active .rx-toc a,
.rx-scrollspy-active .rx-table-of-contents a,
.rx-scrollspy-active .rx-sidebar-toc a {
transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
}
.rx-scrollspy-active .rx-toc a.is-active,
.rx-scrollspy-active .rx-table-of-contents a.is-active,
.rx-scrollspy-active .rx-sidebar-toc a.is-active {
color: var(--rx-color-primary, #0f766e);
font-weight: 700;
border-left-color: var(--rx-color-primary, #0f766e);
}
.rx-scrollspy-active .rx-current-section {
scroll-margin-top: 120px;
}
.rx-reading-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
z-index: 99999;
background: transparent;
}
.rx-reading-progress-bar {
display: block;
width: 0;
height: 100%;
background: var(--rx-color-primary, #0f766e);
transition: width 0.1s linear;
}
Example HTML structure:
<div class="rx-reading-progress" aria-hidden="true">
<span class="rx-reading-progress-bar"></span>
</div>
<nav class="rx-toc" aria-label="Article table of contents">
<ul>
<li><a href="#definition">Definition</a></li>
<li><a href="#causes">Causes</a></li>
<li><a href="#symptoms">Symptoms</a></li>
<li><a href="#diagnosis">Diagnosis</a></li>
</ul>
</nav>
Enqueue it in functions.php:
function rx_theme_enqueue_scrollspy_chunk() {
wp_enqueue_script(
'rx-scrollspy-chunk',
get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-044-scrollspy.js',
array(),
'1.0.0',
true
);
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_scrollspy_chunk' );
Optional custom configuration before the script loads:
function rx_theme_scrollspy_config() {
?>
<script>
window.rxScrollSpyConfig = {
contentSelector: '.rx-single-content, .entry-content',
tocSelector: '.rx-toc, .rx-sidebar-toc',
headingSelector: 'h2, h3',
updateHash: true,
smoothScroll: true,
scrollOffsetExtra: 20
};
</script>
<?php
}
add_action( 'wp_head', 'rx_theme_scrollspy_config', 20 );
Best file position in RX Theme:
rx-theme/
└── assets/
└── static-js/
└── static-chunks-js/
└── chunk-044-scrollspy.js
This chunk is best for long medical articles, A-Z disease pages, documentation pages, treatment guideline pages, and sidebar table-of-content navigation.