notes
Sep 14, 2025 · 1 min read

A Table of Contents Without a Framework

Longer posts on this site were starting to need some kind of in-page navigation — a sidebar list of headings, ideally one that highlights the section currently in view. My first instinct was to look for an npm package. My second, better instinct was to remember this site’s entire premise is shipping close to zero JavaScript, and a table of contents is not a good reason to break that.

Building the actual list

Extracting the headings is the easy half — Astro’s markdown rendering already gives every heading an id, so a build-time step just walks the rendered HTML and collects h2/h3 text and ids into a list, no client JS required for that part at all.

The part that needs the browser

Highlighting the current section does need to run in the browser, since it depends on scroll position. An IntersectionObserver on each heading, rather than a scroll listener doing math on every frame, keeps it cheap:

const headings = document.querySelectorAll('article :is(h2, h3)[id]');
const tocLinks = document.querySelectorAll('.toc a');

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    tocLinks.forEach((link) => link.classList.remove('is-active'));
    document
      .querySelector(`.toc a[href="#${entry.target.id}"]`)
      ?.classList.add('is-active');
  }
}, { rootMargin: '-20% 0px -70% 0px' });

headings.forEach((heading) => observer.observe(heading));

Forty lines, no dependency, and it only runs on the handful of posts long enough to actually need a table of contents in the first place, since the script only attaches if the page has more than a few headings to begin with.