Auto Table of Contents generator (tiny JS)

December 8, 2025

Long pages are great for SEO and deep info — but readers love a quick map. This tiny auto Table of Contents (TOC) script scans your page for H2/H3 headings, builds a clean TOC, and adds smooth scrolling. It also auto-generates heading IDs if you didn’t set any.

You can drop this into any blog post template, documentation page, or landing page. If the script doesn’t find a TOC container, it will create one automatically and place it at the top of your content.

<style>
  /* Vibe TOC — clean, compact, no framework */

  .vibe-toc {
    border: 1px solid #C4C6F5;
    background: #F9FAFF;
    padding: 10px 12px;
    margin: 0 0 16px 0;
    border-radius: 10px;
    font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont,
          "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    max-width: 820px;
  }

  .vibe-toc-title {
    font-weight: 700;
    margin: 0 0 6px 0;
    font-size: 13px;
  }

  .vibe-toc-list {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .vibe-toc-item {
    margin: 3px 0;
  }

  .vibe-toc-link {
    text-decoration: none;
    border-bottom: 1px dotted #A829A7;
    color: #000;
  }

  .vibe-toc-link:hover {
    color: #A829A7;
  }

  .vibe-toc-item.is-h3 {
    padding-left: 14px;
    font-size: 12px;
    opacity: 0.9;
  }

  .vibe-toc-link.is-active {
    font-weight: 700;
  }

  /* Optional: make it slightly sticky on wide screens */
  @media (min-width: 980px) {
    .vibe-toc.vibe-toc--sticky {
      position: sticky;
      top: 14px;
    }
  }
</style>

<!-- Optional placeholder.
     If you omit this, the script will create one automatically. -->
<div id="vibe-toc" class="vibe-toc"></div>

<script>
  // Vibe Auto TOC
  // - Scans H2/H3 inside a chosen content container
  // - Builds a lightweight TOC
  // - Auto-adds IDs to headings
  // - Smooth scroll + active highlight

  (function () {
    function slugify(str) {
      return String(str || '')
        .toLowerCase()
        .trim()
        .replace(/['"]/g, '')
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '') || 'section';
    }

    function uniqueId(base, used) {
      var id = base;
      var i = 2;
      while (used[id]) {
        id = base + '-' + i;
        i++;
      }
      used[id] = true;
      return id;
    }

    function getHeadings(container) {
      return Array.prototype.slice.call(
        container.querySelectorAll('h2, h3')
      );
    }

    function buildTOC(headings) {
      var ul = document.createElement('ul');
      ul.className = 'vibe-toc-list';

      headings.forEach(function (h) {
        var li = document.createElement('li');
        li.className = 'vibe-toc-item ' + (h.tagName === 'H3' ? 'is-h3' : 'is-h2');

        var a = document.createElement('a');
        a.className = 'vibe-toc-link';
        a.href = '#' + h.id;
        a.textContent = h.textContent || h.innerText || 'Section';

        a.addEventListener('click', function (e) {
          // Smooth scroll without fighting browser defaults
          e.preventDefault();
          var target = document.getElementById(h.id);
          if (!target) return;

          var y = target.getBoundingClientRect().top + window.pageYOffset - 8;
          window.scrollTo({ top: y, behavior: 'smooth' });

          // Update URL hash quietly
          if (history && history.replaceState) {
            history.replaceState(null, '', '#' + h.id);
          }
        });

        li.appendChild(a);
        ul.appendChild(li);
      });

      return ul;
    }

    function highlightActive(headings, tocRoot) {
      var links = Array.prototype.slice.call(
        tocRoot.querySelectorAll('.vibe-toc-link')
      );

      function setActive(id) {
        links.forEach(function (a) {
          a.classList.toggle('is-active', a.getAttribute('href') === '#' + id);
        });
      }

      function onScroll() {
        var current = null;
        var offset = 14;

        for (var i = 0; i < headings.length; i++) {
          var rect = headings[i].getBoundingClientRect();
          if (rect.top - offset <= 0) {
            current = headings[i];
          } else {
            break;
          }
        }

        if (current && current.id) {
          setActive(current.id);
        }
      }

      window.addEventListener('scroll', onScroll, { passive: true });
      onScroll();
    }

    document.addEventListener('DOMContentLoaded', function () {
      // You can change this selector to match your site structure.
      // If not found, we fall back to body.
      var content =
        document.querySelector('.vibe-body') ||
        document.querySelector('main') ||
        document.querySelector('.content') ||
        document.body;

      var headings = getHeadings(content);
      if (!headings.length) return;

      // Track used IDs (existing + generated)
      var used = {};
      Array.prototype.forEach.call(document.querySelectorAll('[id]'), function (el) {
        used[el.id] = true;
      });

      // Ensure each heading has a unique id
      headings.forEach(function (h) {
        if (!h.id) {
          var base = slugify(h.textContent || h.innerText);
          h.id = uniqueId(base, used);
        } else {
          used[h.id] = true;
        }
      });

      var toc = document.getElementById('vibe-toc');
      if (!toc) {
        toc = document.createElement('div');
        toc.id = 'vibe-toc';
        toc.className = 'vibe-toc';

        // Insert at top of content
        if (content.firstChild) {
          content.insertBefore(toc, content.firstChild);
        } else {
          content.appendChild(toc);
        }
      }

      // Add title + list
      toc.innerHTML = '';
      var title = document.createElement('div');
      title.className = 'vibe-toc-title';
      title.textContent = 'On this page';
      toc.appendChild(title);

      toc.appendChild(buildTOC(headings));

      // Optional sticky flavor on wide screens
      toc.classList.add('vibe-toc--sticky');

      // Active highlight
      highlightActive(headings, toc);
    });
  })();
</script>

Usage notes

This script is intentionally flexible: it will look for a likely content container (.vibe-body, main, or .content) and fall back to document.body. If you want it to be strict, just change the selector near the top.

For best results, use meaningful H2/H3 headings. Your readers get instant navigation, and search engines get clearer structure.

Comments (0)

No comments yet — be the first.

← Back to all scripts