Vanilla JavaScript Projects

Project 59, Website sections

Scrollspy Table of Contents

The sidebar you see on good documentation and long guides. It builds itself from your headings and always shows where you are.

Main API
IntersectionObserver
Extra
scrollIntoView
Dependencies
None
Your browser
Checking

Read the guide

Scroll the article on the right. The table of contents is built from the headings and follows along.

Getting started

Install nothing. Copy the single HTML file into your project and open it in a browser. Everything runs locally.

The demo uses one article and one sidebar, but the same code works on a whole documentation site.

Requirements

Any modern browser. IntersectionObserver has been supported everywhere since 2019.

Writing content

Use normal h2 and h3 headings. The script reads them, gives each one an id and builds the list.

Short headings work best. Aim for three to six words.

Linking to a section

Because every heading gets an id, you can share a link straight to it, like page.html#writing-content.

Customising the look

The active link gets a class. Style it however you like: bold, coloured, with a bar on the left.

The progress bar under the list shows how far through the page you are.

Dark mode

Colours come from CSS variables, so the theme button switches everything at once.

Performance

There is no scroll listener for the highlight. The browser tells the observer when a heading enters the reading zone, which costs almost nothing.

Frequently asked questions

Can I use it with a static site generator? Yes. It only needs headings in the final HTML.

Does it work with lazy loaded content? Call the build function again after new content arrives.

How it works

  1. Build from headingsThe script finds every h3 and h4, gives each a clean id and writes the list of links.
  2. Watch a reading zonerootMargin shrinks the viewport to its top 30 percent. The heading that enters that zone is the current section.
  3. Scroll and focusClicking a link scrolls smoothly and moves keyboard focus to the heading, so screen readers follow too.
const io = new IntersectionObserver((entries) => {
  const visible = entries.filter((e) => e.isIntersecting);
  if (visible[0]) highlight(visible[0].target.id);
}, { rootMargin: "0px 0px -70% 0px" }); // top 30% of the screen

document.querySelectorAll("h2, h3").forEach((h) => {
  h.id ||= h.textContent.toLowerCase().replace(/[^a-z0-9]+/g, "-");
  io.observe(h);
});