Auto Table of Contents generator (tiny JS)
Long pages are great for SEO and deep info — but readers love a quick map. This tiny auto Table of Contents (TOC) script scans your page for H2/H3 headings, builds a clean TOC, and adds smooth scrolling. It also auto-generates heading IDs if you didn’t set any.
You can drop this into any blog post template, documentation page, or landing page. If the script doesn’t find a TOC container, it will create one automatically and place it at the top of your content.
<style>
/* Vibe TOC — clean, compact, no framework */
.vibe-toc {
border: 1px solid #C4C6F5;
background: #F9FAFF;
padding: 10px 12px;
margin: 0 0 16px 0;
border-radius: 10px;
font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 820px;
}
.vibe-toc-title {
font-weight: 700;
margin: 0 0 6px 0;
font-size: 13px;
}
.vibe-toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.vibe-toc-item {
margin: 3px 0;
}
.vibe-toc-link {
text-decoration: none;
border-bottom: 1px dotted #A829A7;
color: #000;
}
.vibe-toc-link:hover {
color: #A829A7;
}
.vibe-toc-item.is-h3 {
padding-left: 14px;
font-size: 12px;
opacity: 0.9;
}
.vibe-toc-link.is-active {
font-weight: 700;
}
/* Optional: make it slightly sticky on wide screens */
@media (min-width: 980px) {
.vibe-toc.vibe-toc--sticky {
position: sticky;
top: 14px;
}
}
</style>
<!-- Optional placeholder.
If you omit this, the script will create one automatically. -->
<div id="vibe-toc" class="vibe-toc"></div>
<script>
// Vibe Auto TOC
// - Scans H2/H3 inside a chosen content container
// - Builds a lightweight TOC
// - Auto-adds IDs to headings
// - Smooth scroll + active highlight
(function () {
function slugify(str) {
return String(str || '')
.toLowerCase()
.trim()
.replace(/['"]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
}
function uniqueId(base, used) {
var id = base;
var i = 2;
while (used[id]) {
id = base + '-' + i;
i++;
}
used[id] = true;
return id;
}
function getHeadings(container) {
return Array.prototype.slice.call(
container.querySelectorAll('h2, h3')
);
}
function buildTOC(headings) {
var ul = document.createElement('ul');
ul.className = 'vibe-toc-list';
headings.forEach(function (h) {
var li = document.createElement('li');
li.className = 'vibe-toc-item ' + (h.tagName === 'H3' ? 'is-h3' : 'is-h2');
var a = document.createElement('a');
a.className = 'vibe-toc-link';
a.href = '#' + h.id;
a.textContent = h.textContent || h.innerText || 'Section';
a.addEventListener('click', function (e) {
// Smooth scroll without fighting browser defaults
e.preventDefault();
var target = document.getElementById(h.id);
if (!target) return;
var y = target.getBoundingClientRect().top + window.pageYOffset - 8;
window.scrollTo({ top: y, behavior: 'smooth' });
// Update URL hash quietly
if (history && history.replaceState) {
history.replaceState(null, '', '#' + h.id);
}
});
li.appendChild(a);
ul.appendChild(li);
});
return ul;
}
function highlightActive(headings, tocRoot) {
var links = Array.prototype.slice.call(
tocRoot.querySelectorAll('.vibe-toc-link')
);
function setActive(id) {
links.forEach(function (a) {
a.classList.toggle('is-active', a.getAttribute('href') === '#' + id);
});
}
function onScroll() {
var current = null;
var offset = 14;
for (var i = 0; i < headings.length; i++) {
var rect = headings[i].getBoundingClientRect();
if (rect.top - offset <= 0) {
current = headings[i];
} else {
break;
}
}
if (current && current.id) {
setActive(current.id);
}
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
document.addEventListener('DOMContentLoaded', function () {
// You can change this selector to match your site structure.
// If not found, we fall back to body.
var content =
document.querySelector('.vibe-body') ||
document.querySelector('main') ||
document.querySelector('.content') ||
document.body;
var headings = getHeadings(content);
if (!headings.length) return;
// Track used IDs (existing + generated)
var used = {};
Array.prototype.forEach.call(document.querySelectorAll('[id]'), function (el) {
used[el.id] = true;
});
// Ensure each heading has a unique id
headings.forEach(function (h) {
if (!h.id) {
var base = slugify(h.textContent || h.innerText);
h.id = uniqueId(base, used);
} else {
used[h.id] = true;
}
});
var toc = document.getElementById('vibe-toc');
if (!toc) {
toc = document.createElement('div');
toc.id = 'vibe-toc';
toc.className = 'vibe-toc';
// Insert at top of content
if (content.firstChild) {
content.insertBefore(toc, content.firstChild);
} else {
content.appendChild(toc);
}
}
// Add title + list
toc.innerHTML = '';
var title = document.createElement('div');
title.className = 'vibe-toc-title';
title.textContent = 'On this page';
toc.appendChild(title);
toc.appendChild(buildTOC(headings));
// Optional sticky flavor on wide screens
toc.classList.add('vibe-toc--sticky');
// Active highlight
highlightActive(headings, toc);
});
})();
</script>
Usage notes
This script is intentionally flexible:
it will look for a likely content container
(.vibe-body, main, or .content)
and fall back to document.body.
If you want it to be strict, just change the selector near the top.
For best results, use meaningful H2/H3 headings. Your readers get instant navigation, and search engines get clearer structure.
Comments (0)
No comments yet — be the first.