You need JavaScript to build an automatic table of contents in Webflow CMS. There is no native way to do it. The CMS rich text editor does not let you add IDs to headings, so you cannot create anchor links to specific sections without a script that generates those IDs at runtime.
The good news: the script is small, it runs on page load, and once you add it to your CMS blog template, it works on every post automatically. No per-post configuration. No plugins. No third-party tools.
Here is the complete approach I use on client sites, from the HTML structure to the JavaScript to active-state scroll highlighting.
Why Webflow CMS Makes This Tricky
On a static Webflow page, you can manually add an ID to any element through the settings panel. Select a heading, type an ID like "pricing," and you have a working anchor target. Easy.
CMS blog posts are different. Content lives inside a rich text field, and the rich text editor gives you formatting controls (bold, italic, links, lists) but not HTML attribute controls. You cannot set an ID on an H2 inside rich text. Webflow simply does not expose that option.
This means you need a script that runs after the page loads, finds every heading inside the rich text block, generates a URL-friendly ID from the heading text, assigns that ID to the heading element, and builds a linked list of those headings inside a container you designate as the table of contents.
It sounds like a lot. The actual code is about 40 lines.
Set Up the HTML Structure in Webflow
Before writing any code, set up two elements on your CMS blog template page.
First, your rich text element. This already exists since it is bound to your CMS post body. Give it a custom attribute: data-toc="content". You will use this attribute to tell the script where to find headings.
Second, create an empty Div Block where you want the table of contents to appear. Common placements are:
- A sidebar that scrolls with the reader (sticky sidebar)
- A section at the top of the post, below the title and above the body
- A floating panel that can be toggled open and closed
Give this div a custom attribute: data-toc="container". The script will inject the generated TOC links into this element.
If you want the TOC in a sticky sidebar, set the sidebar wrapper to display: flex, place the rich text and the TOC container side by side, and set the TOC container to position: sticky with a top value that clears your navbar. Something like top: 96px if your nav is 80px tall.
The JavaScript That Builds the Table of Contents
Add this script to the before-body custom code section of your CMS blog template page (not site-wide, just the template):
<script>
document.addEventListener('DOMContentLoaded', function() {
const content = document.querySelector('[data-toc="content"]');
const tocContainer = document.querySelector('[data-toc="container"]');
if (!content || !tocContainer) return;
const headings = content.querySelectorAll('h2, h3');
if (headings.length < 2) {
tocContainer.style.display = 'none';
return;
}
const tocList = document.createElement('ul');
tocList.classList.add('toc-list');
headings.forEach(function(heading, index) {
// Generate a URL-friendly ID from the heading text
const id = heading.textContent
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
|| 'section-' + index;
heading.setAttribute('id', id);
const listItem = document.createElement('li');
listItem.classList.add('toc-item');
if (heading.tagName === 'H3') {
listItem.classList.add('toc-item-nested');
}
const link = document.createElement('a');
link.setAttribute('href', '#' + id);
link.textContent = heading.textContent;
link.classList.add('toc-link');
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
});
</script>
Let me walk through what this does:
- It waits for the DOM to load.
- It finds the rich text element and the TOC container using the data attributes.
- It grabs all H2 and H3 elements inside the rich text.
- If there are fewer than 2 headings, it hides the TOC container entirely. A table of contents with one item is pointless.
- For each heading, it converts the text to a URL-safe slug (lowercase, hyphens, no special characters) and sets that as the element's ID.
- It creates a linked list item for each heading, with H3s getting a nested class for indentation.
The fallback 'section-' + index handles the edge case where a heading contains only special characters or emoji that would produce an empty slug.
Style the Table of Contents
The script adds CSS classes that you can style in Webflow or with custom CSS. Here is a baseline stylesheet:
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-item {
margin-bottom: 8px;
}
.toc-item-nested {
padding-left: 16px;
}
.toc-link {
color: #555;
text-decoration: none;
font-size: 14px;
line-height: 1.5;
transition: color 0.2s ease;
}
.toc-link:hover {
color: #000;
}
.toc-link.active {
color: #000;
font-weight: 600;
}
Add this to your page's head custom code inside a <style> tag. Adjust colors and sizes to match your site's design system.
Want a website that turns visitors into customers, not just compliments?
Book a 15-min introAdd Active-State Highlighting on Scroll
A static list of links is functional but not great UX. The best table of contents implementations highlight the current section as the reader scrolls. This tells the reader exactly where they are in the post and makes the TOC feel interactive.
Add this script right after the TOC generation script:
<script>
document.addEventListener('DOMContentLoaded', function() {
const tocLinks = document.querySelectorAll('.toc-link');
const headings = document.querySelectorAll('[data-toc="content"] h2, [data-toc="content"] h3');
if (headings.length === 0 || tocLinks.length === 0) return;
const observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
tocLinks.forEach(function(link) {
link.classList.remove('active');
});
const activeLink = document.querySelector('.toc-link[href="#' + entry.target.id + '"]');
if (activeLink) {
activeLink.classList.add('active');
}
}
});
}, {
rootMargin: '-80px 0px -80% 0px',
threshold: 0
});
headings.forEach(function(heading) {
observer.observe(heading);
});
});
</script>
This uses the Intersection Observer API instead of a scroll event listener. It is significantly more performant because the browser handles the observation natively rather than firing a callback on every scroll pixel.
The rootMargin value -80px 0px -80% 0px means: consider a heading "in view" when it is 80px below the top of the viewport (clearing the sticky nav) and in the top 20% of the screen. This makes the active state feel natural. The heading highlights as you reach it, not after you have scrolled past it.
Make It Work with scroll-padding-top
If you have a sticky navbar (and you probably do), your anchor links need scroll-padding-top to land in the right spot. I covered this in detail in my post on fixing anchor links with sticky navbars.
The short version: add this to your head custom code:
html {
scroll-padding-top: 88px;
}
Set the value to your navbar height plus a small buffer. This ensures that when a reader clicks a TOC link, the target heading lands below the navbar, not behind it.
Without this, your TOC technically works but the UX is broken. The reader clicks a link, the heading scrolls behind the nav, and they cannot see the section title. It is the number one complaint I see on Webflow blogs with table of contents implementations.
This Works on Static Pages Too
The script targets elements by data attributes, not by CMS-specific selectors. That means you can use the exact same setup on static pages.
Building a long landing page with multiple sections? Add data-toc="content" to a wrapper div around your sections, make sure those sections have H2 headings, drop an empty div with data-toc="container" in a sidebar or top section, and the script generates the TOC automatically.
I use this on long service pages where the content covers multiple topics. A landing page for Webflow development services that covers design, development, CMS setup, and launch can easily hit 3,000+ words. A TOC makes that page navigable instead of intimidating.
SEO and AI-Citation Benefits
A well-structured table of contents does more than help readers navigate. It signals to search engines that your content is organized and comprehensive.
Google sometimes displays TOC links as jump links directly in search results. These sitelinks-style links appear below your page's main result and let searchers jump straight to the section they care about. You cannot force Google to show them, but having proper heading IDs with descriptive anchor text gives you the best chance.
For AI citation, the impact is even more direct. AI systems like Google's AI Overviews and ChatGPT pull information from well-structured pages. A table of contents with descriptive headings helps these systems understand what each section covers and makes it more likely that your content gets cited for specific questions.
Long-form posts with 5+ sections benefit the most. Short posts with 2-3 sections can skip the TOC entirely, which is why the script hides the container when there are fewer than 2 headings.
Handling Edge Cases
A few things to watch for in production:
Duplicate heading text. If two headings in the same post have identical text (unlikely but possible), the script generates duplicate IDs. The browser will scroll to the first one for both links. To fix this, you can modify the ID generation to append the index:
const id = slug + (existingSlugs.has(slug) ? '-' + index : '');
But honestly, if you have duplicate headings, the bigger issue is your content structure.
Very long heading text. A 15-word heading generates a very long slug. You can truncate the ID to the first 5-6 words by adding .split('-').slice(0, 6).join('-') to the slug generation. The ID does not need to be human-readable since users never see it directly.
Rich text embeds and code blocks. The script only targets H2 and H3 elements, so embedded code blocks, images, and other rich text elements are ignored. No special handling needed.
Empty headings. If someone accidentally adds an empty heading in the CMS editor, the script generates a fallback ID of section-{index} and creates a TOC item with no text. You can add a check to skip empty headings:
if (!heading.textContent.trim()) return;
Putting It All Together
The complete implementation requires three pieces:
- Two data attributes on your Webflow elements (the rich text and the TOC container)
- The TOC generation script in the before-body custom code
- The active-state observer script right after the generation script
Optional but recommended: scroll-padding-top for sticky nav offset, and custom CSS to style the TOC to match your design system.
Once it is set up on your CMS template, every blog post gets a working table of contents without any per-post effort. Your content editors write posts, add headings, and the TOC handles itself.
If you want this built into your Webflow blog or need help with CMS architecture, I am available for strategy sessions or full builds. And if you are an agency that needs a white-label Webflow developer who handles these technical details, let me know.