Webflow

How to Add a Dynamic Table of Contents to Webflow CMS Blog Posts (No Plugins)

TS Talha Shahzad··9 min read
The short version
  • Webflow CMS rich text headings have no manual IDs, so JavaScript generates them
  • A single script works across every CMS blog post without per-post configuration
  • Active-state highlighting on scroll significantly improves reading UX
  • TOCs boost SEO by signaling clear content structure to search engines
  • The same approach works on both CMS template pages and static pages

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:

  1. It waits for the DOM to load.
  2. It finds the rich text element and the TOC container using the data attributes.
  3. It grabs all H2 and H3 elements inside the rich text.
  4. If there are fewer than 2 headings, it hides the TOC container entirely. A table of contents with one item is pointless.
  5. 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.
  6. 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 intro

Add 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:

  1. Two data attributes on your Webflow elements (the rich text and the TOC container)
  2. The TOC generation script in the before-body custom code
  3. 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.

Prefer to hire through Upwork?
Top Rated Plus, 100% Job Success, 450+ projects shipped. See the reviews and start a contract.
Hire me on Upwork

FAQ

Can I add a table of contents to Webflow CMS posts without a plugin?

Yes. A small JavaScript snippet reads all headings inside your rich text element, generates unique IDs, and builds a linked table of contents automatically.

Why can't I just add IDs to headings in the Webflow CMS editor?

The Webflow rich text editor does not expose ID fields for headings. You can add links and formatting, but not HTML attributes like IDs. JavaScript must generate them.

Does a table of contents help with SEO?

Yes. Google can display TOC links as jump links in search results, and the structured navigation signals well-organized content. Long posts benefit the most.

All posts
the next step is small

Want a site that does this for you?

15 minutes, no deck, no pressure. Worst case, you leave with a free plan.

keep reading

More notes