chunk-044-scrollspy.js

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.

Leave a Reply

Your email address will not be published. Required fields are marked *