How to add a table of contents to a Webflow blog / CMS entry

Tom's profile picture

Tom Bruining

Co-founder

3 min read

Chat with this article

Adding a table of contents to Webflow CMS entries is surprisingly tricky. Webflow doesn’t give you anything useful in this regard out of the box. The only way to do it is by using javascript and some workarounds.

In this article we’ll explain the process for creating a Table of Contents and provide the code that we use in our own blogs. You’ll be able to use this same javascript to set it up on your website with a few little tweaks.

While we took inspiration from the Finsweet styling framework, our script is a bit more robust and will handle different blog structures more gracefully.

Step by step

First things first, to create a Table of Contents in Webflow we are going to use three different components:

  1. A rich text field
  2. A set of link and text elements that are used as the styled components for our table of contents.
  3. An embedded “Code Editor” script that will run when the web page loads.

The rich text field

The rich text field should be the easiest to handle. Your blog content is most likely already in a rich text component on the page.

Find the rich text field and add the class blogpost_content  to it.

If you have multiple rich text fields because you’ve split your blog posts in half with a CTA element, you can wrap all of them in a single div and give it the blogpost_content class. The script will automatically loop through all levels of this parent div to find any headings.

The TOC links

The more complicated stage is to add a new set of DIVs, text and link elements into your page where you would like the TOC to be placed.

The structure should look like this:

div - class: hg-toc_link-wrapper (optional: an attribute called hg-scroll-margin)		div  - class: hg-toc_link-wrapper		    	Link - class: hg-toc_link		        Div - class: nested-toc			        	div  - class: hg-toc_link-wrapper				            	Div - class: nested-toc

You can apply any styles you want to each of these levels.

The script to add the TOC to your page

Place an “embedded code” tag anywhere on the page, I suggest placing it right above where your TOC link elements are. This keeps everything nicely structured and organized. 

<script>document.addEventListener('DOMContentLoaded', function() {    // Function to generate a unique ID for each heading    function generateId(text) {        return text.toLowerCase().replace(/[^a-z0-9]+/g, '-');    }
    // Find the template .hg-toc_link-wrapper and remove it from the DOM to use as a template    var templateWrapper = document.querySelector('.hg-toc_link-wrapper').cloneNode(true);    var scrollMargin = templateWrapper.getAttribute('hg-scroll-margin');    templateWrapper.classList.remove('is-h2');    document.querySelector('.hg-toc_link-wrapper').remove();
    // Initialize an array to store the TOC links    var tocLinks = [];    var tocMap = {};    var currentH2Wrapper = null;
    // Find all h1, h2, h3, and h4 tags within the descendants of blogpost05_content    var blogpostContent = document.querySelector('.blogpost05_content');    var headings = blogpostContent.querySelectorAll('h1, h2, h3');    headings.forEach(function(heading, index) {        // If the element does not have an ID, generate one        const id = heading.id || generateId(heading.textContent);        heading.setAttribute("hg-generated-id", id)
        // Create an anchor element and insert it before the heading        var anchor = document.createElement('a');        anchor.className = 'anchor';        anchor.id = id;        heading.id = '';  // Remove the ID from the heading        anchor.style.cssText = `display: block; position: relative; top: -${scrollMargin}px; visibility: hidden;`;        heading.parentNode.insertBefore(anchor, heading);
        // Determine the heading level and create the appropriate link        var headingLevel = heading.nodeName.toLowerCase();        var linkClass = 'is-' + headingLevel;
        // Clone the template, set the link properties, and add the appropriate class        var newLinkWrapper = templateWrapper.cloneNode(true);        newLinkWrapper.classList.add(linkClass);        var link = newLinkWrapper.querySelector('a');        link.setAttribute('hg-toc-element', 'link-' + (index + 1));        link.setAttribute('href', '#' + anchor.id);        link.textContent = heading.textContent;
        // Map the heading ID to the corresponding TOC link        tocMap[id] = link;
        if (headingLevel === 'h2') {            // If the current heading is h2, add it to the TOC links array            tocLinks.push(newLinkWrapper);            currentH2Wrapper = newLinkWrapper;        }         else if (headingLevel === 'h3' || headingLevel === 'h4') {            // If the current heading is h3 or h4, nest it inside the last h2 wrapper            if (currentH2Wrapper) {                var nestedToc = currentH2Wrapper.querySelector('.nested-toc');                if (!nestedToc) {                    nestedToc = document.createElement('div');                    nestedToc.classList.add('nested-toc');                    currentH2Wrapper.appendChild(nestedToc);                }                nestedToc.appendChild(newLinkWrapper);            } else {                // If no h2 is present, add it at the top level                tocLinks.push(newLinkWrapper);            }        }    });
    // Find the container for the TOC links    var tocContent = document.querySelector('.hg-toc_link-content');
    // Append the new link wrappers to the TOC container    tocLinks.forEach(function(linkWrapper) {        tocContent.appendChild(linkWrapper);    });
    // Remove empty nested-toc elements    var nestedTocs = document.querySelectorAll('.nested-toc');    nestedTocs.forEach(function(nestedToc) {        if (!nestedToc.querySelector('.hg-toc_link-wrapper')) {            nestedToc.remove();        }    });
    // Create an Intersection Observer to observe the headings    var observer = new IntersectionObserver(function(entries) {        entries.forEach(function(entry) {            if (entry.isIntersecting) {                                // Add "current" class to the corresponding TOC link                var tocLink = tocMap[entry.target.getAttribute("hg-generated-id")];                if (tocLink) {                    tocLink.classList.add('current');                }            } else {            	// Add "current" class to the corresponding TOC link                var tocLink = tocMap[entry.target.getAttribute("hg-generated-id")];                if (tocLink) {                    tocLink.classList.remove('current');                }            }        });    }, { rootMargin: `-${scrollMargin}px 0px 0px 0px`, threshold: 1 });
    // Observe each heading    headings.forEach(function(heading) {        observer.observe(heading);    });});</script>

How this works

The script will run when the page has finished loading, it will find any H2, H3, or H4 tags in your blog post and will collect them all in a list as long as the content has the class “blogpost_content” on it.

The script will then find the template TOC links you have added to the page with the class names - hg-toc_link-wrapper, hg-toc_link and nested-toc.

This structure lets you style the links in your toc consistently, and add indents to each level of the toc.

The script will then automatically create an <a> tag directly above each of the headings with an ID that has been generated for the heading. 

It will then create all of the links in your TOC and add the id links to each of them.

Finally, the script will create what is called an “intersection observer”. This is a really fast way for a browser to track the scroll position of a reader. As you scroll down the page, the script will add a “current” class to the links in the toc, allowing you to style them as you see fit.