Santaji GadeJavaScript2 days ago8 Views

Automatically generate table of contents from headings using vanilla JS — no library needed. Slugified IDs, nesting, and active-section highlighting.
Table of Contents
ToggleAutomatically generating a table of contents from headings comes down to three steps: query every heading in your content, generate a unique, URL-safe ID for each one, then build a linked list pointing to those IDs. No framework or library is required, vanilla JavaScript handles this in under 40 lines.
A dynamic table of contents stays in sync with your content automatically, so adding or removing a section never leaves a stale, hand-maintained list behind.
Here's the working code for basic generation, collision-safe ID slugification, and active-section highlighting using IntersectionObserver.
Query headings with a scoped selector to avoid pulling in unrelated headings from your header, sidebar, or footer. This example targets only headings inside a content container.
function generateTOC(contentSelector, tocSelector, levels = 'h2, h3') {
const content = document.querySelector(contentSelector);
const tocContainer = document.querySelector(tocSelector);
const headings = content.querySelectorAll(levels);
if (headings.length < 2) return; // Skip TOC on short articles
const list = document.createElement('ol');
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = `section-${index}`;
}
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.textContent = heading.textContent;
listItem.appendChild(link);
list.appendChild(listItem);
});
tocContainer.appendChild(list);
}
generateTOC('.article-content', '#toc');
Auto-generated IDs like "section-0" work but aren't meaningful in a URL. Slugifying the actual heading text creates readable anchors, and a duplicate counter prevents two identical headings from colliding on the same ID.
function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_]+/g, '-') // Replace spaces/underscores with hyphens
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
}
function assignUniqueIds(headings) {
const usedIds = new Set();
headings.forEach(heading => {
if (heading.id) {
usedIds.add(heading.id);
return;
}
let baseId = slugify(heading.textContent);
let finalId = baseId;
let counter = 1;
// Handle duplicate headings by appending a counter
while (usedIds.has(finalId)) {
finalId = `${baseId}-${counter}`;
counter++;
}
heading.id = finalId;
usedIds.add(finalId);
});
}
Wrap the generated list in a nav element with an aria-label to create a proper accessible navigation landmark. This version also nests h3 headings under their parent h2, matching visual and semantic hierarchy.
function generateNestedTOC(contentSelector, tocSelector) {
const content = document.querySelector(contentSelector);
const tocContainer = document.querySelector(tocSelector);
const headings = content.querySelectorAll('h2, h3');
if (headings.length < 2) return;
assignUniqueIds(headings); // From the slugify function above
const nav = document.createElement('nav');
nav.setAttribute('aria-label', 'Table of contents');
let currentList = document.createElement('ol');
let currentSubList = null;
nav.appendChild(currentList);
headings.forEach(heading => {
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.textContent = heading.textContent;
const listItem = document.createElement('li');
listItem.appendChild(link);
if (heading.tagName === 'H2') {
currentList.appendChild(listItem);
currentSubList = document.createElement('ol');
listItem.appendChild(currentSubList);
} else if (currentSubList) {
currentSubList.appendChild(listItem);
}
});
tocContainer.appendChild(nav);
}
IntersectionObserver is the modern, efficient way to highlight the active section as a reader scrolls, avoiding the performance cost of listening to scroll events directly.
function highlightActiveSection(contentSelector, tocSelector) {
const headings = document.querySelectorAll(`${contentSelector} h2, ${contentSelector} h3`);
const tocLinks = document.querySelectorAll(`${tocSelector} a`);
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocLinks.forEach(link => link.classList.remove('active'));
const activeLink = document.querySelector(`${tocSelector} a[href="#${entry.target.id}"]`);
if (activeLink) activeLink.classList.add('active');
}
});
}, { rootMargin: '0px 0px -70% 0px' });
headings.forEach(heading => observer.observe(heading));
}
highlightActiveSection('.article-content', '#toc');
A quick reference for choosing between a custom script and an off-the-shelf library.
| Approach | Best For | Trade-off |
|---|---|---|
| Custom vanilla JS (above) | Full control, no dependencies, small pages | You maintain the code yourself |
| Tocbot (library) | Documentation sites, scrollspy support built in | Small added dependency (~3.6KB) |
| jQuery TOC plugins | Legacy sites already using jQuery | Requires jQuery as a dependency |
| CMS-native TOC blocks | WordPress/CMS users wanting zero code | Less customizable than a hand-written script |
A few things worth confirming before shipping any TOC script to production.
Scope your heading query, use a content container selector, not a bare document.querySelectorAll on the whole page.
Skip TOC generation on short pages, most implementations abort below 2 headings to avoid cluttering brief content.
Slugify heading text for IDs, readable anchors help both users and crawlers understand page structure.
Handle duplicate heading text, a counter suffix prevents ID collisions across repeated section titles.
Wrap the output in a nav with aria-label, this creates a proper accessibility landmark for screen readers.
No. Vanilla JavaScript can generate a fully working, accessible table of contents in under 40 lines, with no external dependencies required.
Without handling this, both would get the same slugified ID, breaking anchor links. A duplicate counter appends a number to the second occurrence to keep IDs unique.
No. Most implementations skip generation on pages with fewer than two headings, since a table of contents adds clutter without real navigational value on short content.
Use IntersectionObserver rather than a scroll event listener. It's more performant and lets you detect which heading is currently in view without constant recalculation.
Yes. Track the current top-level list item while iterating headings, and append any subsequent lower-level heading (like h3) into a nested list under it.
A working TOC needs no library, just query, ID, and link
Slugified text makes anchors readable and meaningful
A duplicate counter prevents ID collisions on repeated headings
Nav with aria-label creates a proper accessibility landmark
IntersectionObserver beats scroll listeners for active highlighting
Most implementations skip TOC generation below 2 headings
Table of contents generation pairs well with broken link detection and canonical tag setup. Explore both guides next.









