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

How to add a table of contents to your Webflow CMS entries
Tom Bruining
October 23, 2024

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.

What our blog template looks like behind the scenes
Capture your first product demo today
Free trial, no credit card required
Edit anything in your UI with HTML demos
Hands on onboarding and support

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. 

1<script>
2document.addEventListener('DOMContentLoaded', function() {
3    // Function to generate a unique ID for each heading
4    function generateId(text) {
5        return text.toLowerCase().replace(/[^a-z0-9]+/g, '-');
6    }
7
8    // Find the template .hg-toc_link-wrapper and remove it from the DOM to use as a template
9    var templateWrapper = document.querySelector('.hg-toc_link-wrapper').cloneNode(true);
10    var scrollMargin = templateWrapper.getAttribute('hg-scroll-margin');
11    templateWrapper.classList.remove('is-h2');
12    document.querySelector('.hg-toc_link-wrapper').remove();
13
14    // Initialize an array to store the TOC links
15    var tocLinks = [];
16    var tocMap = {};
17    var currentH2Wrapper = null;
18
19    // Find all h1, h2, h3, and h4 tags within the descendants of blogpost05_content
20    var blogpostContent = document.querySelector('.blogpost05_content');
21    var headings = blogpostContent.querySelectorAll('h1, h2, h3');
22    headings.forEach(function(heading, index) {
23        // If the element does not have an ID, generate one
24        const id = heading.id || generateId(heading.textContent);
25        heading.setAttribute("hg-generated-id", id)
26
27        // Create an anchor element and insert it before the heading
28        var anchor = document.createElement('a');
29        anchor.className = 'anchor';
30        anchor.id = id;
31        heading.id = '';  // Remove the ID from the heading
32        anchor.style.cssText = `display: block; position: relative; top: -${scrollMargin}px; visibility: hidden;`;
33        heading.parentNode.insertBefore(anchor, heading);
34
35        // Determine the heading level and create the appropriate link
36        var headingLevel = heading.nodeName.toLowerCase();
37        var linkClass = 'is-' + headingLevel;
38
39        // Clone the template, set the link properties, and add the appropriate class
40        var newLinkWrapper = templateWrapper.cloneNode(true);
41        newLinkWrapper.classList.add(linkClass);
42        var link = newLinkWrapper.querySelector('a');
43        link.setAttribute('hg-toc-element', 'link-' + (index + 1));
44        link.setAttribute('href', '#' + anchor.id);
45        link.textContent = heading.textContent;
46
47        // Map the heading ID to the corresponding TOC link
48        tocMap[id] = link;
49
50        if (headingLevel === 'h2') {
51            // If the current heading is h2, add it to the TOC links array
52            tocLinks.push(newLinkWrapper);
53            currentH2Wrapper = newLinkWrapper;
54        } 
55        else if (headingLevel === 'h3' || headingLevel === 'h4') {
56            // If the current heading is h3 or h4, nest it inside the last h2 wrapper
57            if (currentH2Wrapper) {
58                var nestedToc = currentH2Wrapper.querySelector('.nested-toc');
59                if (!nestedToc) {
60                    nestedToc = document.createElement('div');
61                    nestedToc.classList.add('nested-toc');
62                    currentH2Wrapper.appendChild(nestedToc);
63                }
64                nestedToc.appendChild(newLinkWrapper);
65            } else {
66                // If no h2 is present, add it at the top level
67                tocLinks.push(newLinkWrapper);
68            }
69        }
70    });
71
72    // Find the container for the TOC links
73    var tocContent = document.querySelector('.hg-toc_link-content');
74
75    // Append the new link wrappers to the TOC container
76    tocLinks.forEach(function(linkWrapper) {
77        tocContent.appendChild(linkWrapper);
78    });
79
80    // Remove empty nested-toc elements
81    var nestedTocs = document.querySelectorAll('.nested-toc');
82    nestedTocs.forEach(function(nestedToc) {
83        if (!nestedToc.querySelector('.hg-toc_link-wrapper')) {
84            nestedToc.remove();
85        }
86    });
87
88    // Create an Intersection Observer to observe the headings
89    var observer = new IntersectionObserver(function(entries) {
90        entries.forEach(function(entry) {
91            if (entry.isIntersecting) {
92                
93                // Add "current" class to the corresponding TOC link
94                var tocLink = tocMap[entry.target.getAttribute("hg-generated-id")];
95                if (tocLink) {
96                    tocLink.classList.add('current');
97                }
98            } else {
99            	// Add "current" class to the corresponding TOC link
100                var tocLink = tocMap[entry.target.getAttribute("hg-generated-id")];
101                if (tocLink) {
102                    tocLink.classList.remove('current');
103                }
104            }
105        });
106    }, { rootMargin: `-${scrollMargin}px 0px 0px 0px`, threshold: 1 });
107
108    // Observe each heading
109    headings.forEach(function(heading) {
110        observer.observe(heading);
111    });
112});
113</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.

About the Author
Tom Bruining
Founder, BSc of Computer Science & BComm

Tom Bruining is the co-founder of HowdyGo. In the past he was Head of Growth & Marketing at a B2B SaaS and Head of Data & Business Intelligence at HelloFresh, UK.

In this article
Get marketing tips in your inbox
Capture your first product demo today
Free trial, no credit card required
Edit anything in your UI with HTML demos
Hands on onboarding and support