Javascript

If you’re building a blog or documentation site, you’ve probably faced these recurring challenges:

Enhance Your Blog with Dynamic TOC, ScrollSpy, and Code Blocks Using Vanilla JS

If you’re building a blog or documentation site, you’ve probably faced these recurring challenges:

  • Long pages with many headings, making it hard to navigate.
  • Code blocks that need syntax highlighting, copy buttons, and sticky headers.
  • Highlighting the currently visible section as you scroll (“scrollspy” behavior).
  • A simple “back to top” button that doesn’t break the flow.

Today, I’ll walk you through a modular, object-oriented JavaScript solution that solves all of these in one script — without needing jQuery or heavy plugins.

Overview of Features

Our script provides:

  1. Syntax highlighting and copyable code blocks

    • Detects the language and adds a label.
    • Adds a copy button with feedback (Copied! / Failed).
    • Handles dynamically added code blocks automatically.
  2. Dynamic Table of Contents (TOC)

    • Generates a TOC based on headings (h1-h6) inside #content.
    • Assigns IDs automatically using a slugified version of the heading text.
    • Supports Bootstrap’s list-group styling for clean UI.
  3. ScrollSpy

    • Highlights the current heading in the TOC while scrolling.
    • Uses IntersectionObserver for efficient, modern scroll detection.
    • Works with dynamic content and updates automatically.
  4. Back to Top Button

    • Smooth scrolling.
    • Show/hide behavior when scrolling past a threshold.
  5. Reading Time Calculation

    • Computes estimated reading time based on words per minute.

Modular, Object-Oriented Structure

We use object literals for each major feature, making the script easy to maintain and extend.

1. Utilities

// ======================
// Utilities
// ======================
const Utils = {
  slugify(text) {
    return text
      .toLowerCase()
      .trim()
      .replace(/[^a-z0-9]+/g, '-');
  },

  calculateReadingTime(element) {
    if (!element) return 0;
    const wordsPerMinute = 225;
    const text = element.innerText.trim().replace(/\s+/g, ' ');
    const words = text.split(' ').length;
    const minutes = Math.ceil(words / wordsPerMinute);
    return minutes < 1 ? '< 1 min read' : `${minutes} min read`;
  },
};

These helper functions are shared across the modules.

2. Code Blocks Module

// ======================
// Code Blocks
// ======================
const CodeBlocks = {
  init() {
    this.wrapAll();
    this.observe();
  },

  wrapAll() {
    document.querySelectorAll('pre:not(.has-wrapper)').forEach((pre) => {
      pre.classList.add('has-wrapper');

      const code = pre.querySelector('code');
      let language = 'Code';
      if (code) {
        const langClass = Array.from(code.classList).find((cls) =>
          cls.startsWith('language-'),
        );
        if (langClass)
          language = langClass.replace('language-', '').toUpperCase();
      }

      const wrapper = document.createElement('div');
      wrapper.className = 'pre-wrapper';

      const header = document.createElement('div');
      header.className = 'pre-header';

      const langLabel = document.createElement('div');
      langLabel.className = 'language-label';
      langLabel.textContent = `</> ${language}`;

      const copyBtn = document.createElement('button');
      copyBtn.className = 'copy-btn';
      copyBtn.textContent = 'Copy';

      copyBtn.addEventListener('click', async () => {
        try {
          await navigator.clipboard.writeText(code.innerText);
          const original = copyBtn.textContent;
          copyBtn.textContent = 'Copied!';
          setTimeout(() => (copyBtn.textContent = original), 2000);
        } catch {
          copyBtn.textContent = 'Failed';
          setTimeout(() => (copyBtn.textContent = 'Copy'), 1500);
        }
      });

      header.append(langLabel, copyBtn);

      // Insert wrapper before pre, then move pre inside wrapper
      pre.parentNode.insertBefore(wrapper, pre);
      wrapper.append(header, pre);
    });
  },

  observe() {
    const observer = new MutationObserver((mutations, obs) => {
      let foundNew = false;
      mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
          if (
            node.nodeType === 1 &&
            (node.matches('pre') || node.querySelector('pre'))
          )
            foundNew = true;
        });
      });

      if (foundNew) {
        obs.disconnect();
        this.wrapAll();
        TOC.generate();
        obs.observe(document.body, { childList: true, subtree: true });
      }
    });

    observer.observe(document.body, { childList: true, subtree: true });
  },
};
  • wrapAll() wraps <pre> blocks only once (.has-wrapper ensures no duplicates).
  • observe() listens for DOM changes and automatically wraps new code blocks.

3. Table of Contents (TOC)

// ======================
// Table of Contents
// ======================
const TOC = {
  init() {
    this.generate();
    ScrollSpy.init();
  },

  generate() {
    const tocList = document.getElementById('toc-list');
    if (!tocList) return;
    tocList.innerHTML = '';

    const headings = document.querySelectorAll(
      '#content h1, #content h2, #content h3, #content h4, #content h5, #content h6',
    );

    headings.forEach((heading) => {
      if (!heading.id) heading.id = Utils.slugify(heading.textContent);

      const a = document.createElement('a');
      a.href = `#${heading.id}`;
      a.textContent = heading.textContent;
      a.className =
        'list-group-item list-group-item-action text-decoration-none link-light';

      tocList.appendChild(a);
    });
  },
};
  • Generates a clean TOC for #content headings.
  • Links are automatically styled as Bootstrap list-group-item.
  • Works in tandem with the ScrollSpy module.

4. ScrollSpy

// ======================
// ScrollSpy
// ======================
const ScrollSpy = {
  navLinks: new Map(),
  activeId: null,

  init() {
    const tocList = document.getElementById('toc-list');
    if (!tocList) return;

    this.navLinks.clear();
    tocList.querySelectorAll('a[href^="#"]').forEach((link) => {
      const id = link.hash.slice(1);
      if (id) this.navLinks.set(id, link);
    });

    const content = document.getElementById('content');
    if (!content) return;

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (!entry.isIntersecting) continue;
          const { id } = entry.target;
          if (!id || id === this.activeId) continue;

          this.clearActive();
          this.activate(this.navLinks.get(id));
          this.activeId = id;
          break;
        }
      },
      { rootMargin: '-0px 0px -90% 0px', threshold: 0 },
    );

    content
      .querySelectorAll('[id]')
      .forEach((section) => observer.observe(section));
  },

  clearActive() {
    this.navLinks.forEach((link) => link.classList.remove('active'));
  },

  activate(link) {
    if (!link) return;
    link.classList.add('active');
  },
};
  • Uses IntersectionObserver for efficient scroll detection.
  • Highlights only the currently visible section.
  • Updates automatically if your TOC is regenerated.

5. Back to Top Button

// ======================
// Back To Top Button
// ======================
const BackToTop = {
  init() {
    const btn = document.querySelector('.backToTop');
    if (!btn) return;

    btn.addEventListener('click', () =>
      window.scrollTo({ top: 0, behavior: 'smooth' }),
    );

    let ticking = false;
    window.addEventListener('scroll', () => {
      if (!ticking) {
        window.requestAnimationFrame(() => {
          btn.classList.toggle('show', window.scrollY > 400);
          ticking = false;
        });
        ticking = true;
      }
    });
  },
};
  • Shows the button after scrolling down 400px.
  • Smooth scrolling behavior makes it feel natural.

Initialization

Finally, everything comes together on DOMContentLoaded:

// ======================
// Initialization
// ======================
document.addEventListener('DOMContentLoaded', () => {
  hljs.highlightAll();
  CodeBlocks.init();
  TOC.init();

  const readTimeEl = document.getElementById('read-time');
  if (readTimeEl)
    readTimeEl.textContent = Utils.calculateReadingTime(
      document.getElementById('content'),
    );

  BackToTop.init();
});

Why This Approach Works

  • Modular & Object-Oriented: Each feature is isolated — easy to maintain.
  • Vanilla JS Only: No jQuery or heavy libraries.
  • Dynamic Content Friendly: MutationObserver ensures newly added code blocks or headings are handled automatically.
  • Accessible & Semantic: Uses proper IDs, aria considerations can be added easily.
  • Bootstrap Compatible: Uses list-group for TOC and can be themed with Bootstrap classes or custom CSS.

Example TOC Styling

#toc-list .list-group-item.active {
  background-color: #0d6efd;
  color: #fff;
  font-weight: 500;
}

#toc-list .list-group-item:hover {
  background-color: #0b5ed7;
  color: #fff;
}
  • Makes the active TOC link stand out.
  • Hover effect improves discoverability.

Conclusion

With this script, you can:

  • Highlight and copy code effortlessly.
  • Navigate long articles via a dynamic TOC.
  • Keep track of where you are with a modern scrollspy.
  • Show reading time and a convenient back-to-top button.

All with a modular, maintainable, and dependency-free approach.