jQuery / Plugin / jquery.toc — Build a heading-based table of contents with smooth scrolling

jquery.toc finds h1–h6 headings inside a selected region and generates linked contents. Call .toc({ container: "#contents" }) on the article to put the list in a separate destination; clicking an entry scrolls to its heading. Keep article and contents regions separate and decide how headings and regeneration will work.

Source and document structure

This is jakobbossek’s jquery.toc, whose options must not be mixed with similarly named plugins. The reviewed v1.1 source is pinned to a specific commit. Save the raw jquery.toc.js file beside the HTML under that filename. Download JavaScript, not the repository’s HTML viewer page.

The two local files are toc-demo.html and jquery.toc.js. Full jQuery 4.0.0 loads from a CDN. Load jQuery, plugin, then initialization; slim lacks the required effects. The same source was tested with jQuery 4.0.0 and 3.7.1 in Windows Edge.

The plugin gathers every :header within the selected region. The example selects only #article, keeping the contents, document title, and aside outside. Two h3 subtopics follow an h2. Maintain meaningful heading levels; this source does not implement a maximum-depth option.

Complete example with focus and rebuilding

Save UTF-8 as toc-demo.html and open it without a server or build tool. Internet access loads jQuery. These are controlled plain-text headings. The plugin rewrites heading HTML and inserts text into HTML strings; this is not a general-purpose handler for arbitrary supplied headings or interactive links and buttons inside headings.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Generate a scoped table of contents</title>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
  <script src="jquery.toc.js"></script>
  <style>
    html { scroll-behavior: auto; }
    body { font: 16px/1.6 sans-serif; max-width: 760px; margin: 20px; }
    #contents { border: 1px solid teal; padding: 16px; }
    #contents ul { padding-left: 24px; }
    #contents a { overflow-wrap: anywhere; }
    #article p { min-height: 440px; }
    h2:focus, h3:focus { outline: 3px solid teal; }
    button { font: inherit; }
    footer { min-height: 100vh; }
  </style>
</head>
<body>
  <h1>A focused table of contents</h1>
  <nav id="contents" aria-label="Article contents"><p>Contents will be generated when scripts load.</p></nav>
  <button type="button" id="rebuild">Rebuild original example</button>
  <p id="status" role="status">Article text is available below.</p>
  <main id="article">
    <h2 id="existing-overview">Overview</h2><p>First main topic with an existing stable ID.</p>
    <h3>Preparation</h3><p>First subtopic.</p>
    <h3>Verification</h3><p>Second subtopic.</p>
    <h2>Overview</h2><p>A different section with the same displayed title.</p>
  </main>
  <aside><h2>Outside the article</h2><p>This heading must not enter the contents.</p></aside>
  <footer>Space for the final destination.</footer>
  <script>
    jQuery(function ($) {
      if (typeof $.fn.toc !== "function") return;
      const $article = $("#article"), $toc = $("#contents"), original = $article.html();
      const $scroller = $(document.scrollingElement);
      let generation = 0;
      function build() {
        generation++; $scroller.stop(true, false);
        $article.html(original); $toc.empty().off("click.demo");
        $article.toc({ container: "#contents", strip: false, stripAfter: 30, scrollSpeed: 400, scrollOffset: 20 });
        // Replace this example's generated links' click handlers only.
        $toc.find("a").off("click");
        $toc.on("click.demo", "a[href^='#hl_']", function (event) {
          const anchor = document.getElementById(this.hash.slice(1));
          if (!anchor) return;
          event.preventDefault();
          const run = ++generation, heading = anchor.parentElement;
          $scroller.stop(true, false);
          const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
          $scroller.animate({ scrollTop: Math.max(0, $(heading).offset().top - 20) }, reduce ? 0 : 400, function () {
            if (run !== generation) return;
            heading.tabIndex = -1; heading.focus({ preventScroll: true });
            $("#status").text("Arrived: " + heading.textContent);
          });
        });
        $("#status").text("Entries: " + $toc.find("a").length);
      }
      $("#rebuild").on("click", function () { build(); window.scrollTo(0, 0); });
      build();
    });
  </script>
</body>
</html>

The initial result is below. The two Overview labels represent different sections.

Entries: 4
  1. Check four entries: Overview, Preparation, Verification, Overview. The middle two are nested. Outside the article is excluded.
  2. Select the last Overview. After a 400ms movement, its heading stops with 20px above it and receives a focus outline.
  3. Focus an entry with the keyboard and press Enter to confirm the same route.
  4. With reduced motion set to reduce, arrival is immediate.
  5. Press Rebuild original example several times. It returns to the top, keeps Entries: 4, and avoids accumulated lists or nested anchors.
  6. Rebuild during movement; an old completion must not overwrite the new status.

After generation, this example replaces only its generated links’ original click handlers. The supplemental code animates one actual scrolling element, clears queued work, distinguishes canceled runs, focuses the heading, and handles reduced motion. It does not modify the original plugin file. URL fragment and history remain unchanged.

Basic call and actual option behavior

To observe the original behavior, keep the initialization script tags in the HTML and replace all code inside them with the snippet below. Keep file loading, article, and #contents, but remove the rebuild button and status. This basic version uses the plugin’s own scrolling without the extra focus, motion, or rebuild handling.

$("#article").toc({
  container: "#contents",
  strip: false,
  stripAfter: 30,
  scrollSpeed: 400,
  scrollOffset: 20
});

#article chooses where headings are collected; container chooses where the list is inserted. wrapWith controls the wrapper HTML, whose default class is toc_container. Scope styles to #contents so other lists are unaffected.

scrollSpeed is milliseconds. In the basic call, compare 400 with 800 on the same entry, then restore 400. scrollOffset is subtracted from the generated inner anchor’s coordinate: positive 20 leaves space above. The complete example instead aligns the heading box itself at 20px, so the inline anchor position can differ by a few pixels.

In the complete example, changing only the .toc() scrolling options will not change the replacement handler. Change the animate duration 400 or the subtraction 20 there; preserve the reduced-motion duration zero.

The actual truncation condition checks strip. Although defaults include shorten, setting shorten:true alone does not enable that branch. strip:true takes substring up to stripAfter and appends ... to every label, including short ones. This counts string units rather than words and may split combined characters such as emoji. Prefer strip:false when readable full labels fit.

Anchors, existing IDs, and integration limits

The source inserts an a with an ID such as hl_112 inside a heading rather than overwriting its existing ID. existing-overview survives. Generated IDs depend on selected region, order, and level rather than label text, so the duplicate titles in this example have distinct destinations. Unicode text is collected too, but reordering headings can change generated IDs. They are not a stable permalink policy.

The original appends lists and wraps heading HTML again on repeated calls. The complete example restores its original HTML snapshot and empties contents first. As the button name says, it restores the original exercise: later content, form state, or direct event bindings are not preserved. An editor needs a different strategy for current content and stable IDs.

Do not initialize this alongside a theme or WordPress shortcode already generating contents for the same article. The tutorial’s executable example is separate from this site’s own contents system. The original click-handler selection also reaches .toc lists globally, so independent behavior across multiple instances must not be assumed.

If absent, check the local file’s path and actual JavaScript content, load order, headings, and container. For wrong alignment, inspect fixed headers, late-loading images/fonts, and space remaining at the document end. Wrap long labels with CSS and keep the article readable even when no contents are generated.