A table of contents sounds like a small feature until you actually build one: it needs an accurate list of headings, IDs to link to, and — the part that's easy to get wrong — some way of knowing which section is currently in view as the reader scrolls, so the active item highlights correctly.
Getting Headings With IDs at Render Time
Ghost's post content comes through as rendered HTML, so the cleanest place to extract headings is a small Handlebars helper that walks the content once at render time rather than re-parsing it in the browser on every page load:
const cheerio = require('cheerio');
function tableOfContents(html) {
const $ = cheerio.load(html);
const headings = [];
$('h2, h3').each((_, el) => {
const $el = $(el);
const text = $el.text().trim();
const id = text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
$el.attr('id', id);
headings.push({ level: el.tagName, text, id });
});
return { html: $.html(), headings };
}
Slugifying the heading text into an id and writing it back onto the element means the anchor links and the actual scroll targets are generated from the exact same source, so they can never drift out of sync with each other — a bug I hit early on when the ID logic in the template and the ID logic in a separate JS file disagreed on how to handle punctuation.
Rendering the Nested List
With h2 and h3 both present, the table of contents needs actual nesting, not just a flat list — an h3 belongs visually under the h2 before it:
<nav class="toc">
<ul>
{{#each headings}}
{{#if (eq level "h2")}}
<li><a href="#{{id}}">{{text}}</a>
{{!-- subsequent h3s get nested via a small helper that groups by preceding h2 --}}
</li>
{{/if}}
{{/each}}
</ul>
</nav>
In practice I pre-grouped the flat heading list into a nested structure in the tableOfContents helper itself, rather than trying to express the grouping logic in the template — Handlebars is deliberately limited on control flow, and fighting it for something this structural wasn't worth it.
Scroll-Spy Without a Library
Highlighting the current section as the reader scrolls is the part most tutorials skip. IntersectionObserver handles it cleanly without any scroll-event listeners, which matters for performance on long posts:
const headingEls = document.querySelectorAll('.post-content h2, .post-content h3');
const tocLinks = document.querySelectorAll('.toc a');
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = entry.target.id;
tocLinks.forEach((link) => {
link.classList.toggle('active', link.getAttribute('href') === `#${id}`);
});
}
},
{ rootMargin: '-20% 0px -70% 0px' } // treat the upper-middle of the viewport as "current"
);
headingEls.forEach((el) => observer.observe(el));
The rootMargin values are the part that actually took tuning — too generous and two sections both look "active" at once, too narrow and there's a dead zone where nothing highlights while scrolling between headings.
Where This Runs
This theme, table of contents included, is what's actually running on the small self-hosted Ghost instance I set up here — building it against a real deployment target rather than just a local dev server caught a couple of caching edge cases around the generated heading IDs that never showed up in local testing.