How to add a table of contents to a Webflow blog / CMS entry
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:
- A rich text field
- A set of link and text elements that are used as the styled components for our table of contents.
- 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.
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.