← Overview

scrollSpy

Highlights the TOC link of the section currently being read.

Scroll this page. The table of contents on the left travels along and always marks the section you are reading — aria-current="location" moves from link to link as each section's top edge passes the spy line. Click a link: the page jumps (smoothly, unless you prefer reduced motion) and exactly that link lights up. The second example runs the same wiring inside a scroll container.

Default (page TOC with a nested list)

Concept

This column is ordinary content: stacked sections with ids, nothing else. The <nav> on the left is the module element — every in-page anchor link inside it became a spy link when the module booted. Watch the list while you scroll: the moment this section's top edge passes the invisible spy line, its link gains aria-current="location"; the demo stylesheet turns that attribute into the accent bar you see.

Nothing here depends on JavaScript to be usable. Disable it and the TOC is still a list of working jump links — there is simply no highlight, and honestly so: a marker that could not update would be worse than none.

There is also no scroll listener running and nothing is measured per frame. IntersectionObservers report only the moments a section edge crosses the line; between two crossings this page costs nothing.

How it works

"Currently being read" is decided at a spy line — a horizontal line a fixed distance below the viewport's top edge. Current is the last section in document order whose top edge has passed that line. This entry has two children in the TOC: as you read on, the highlight wanders into the sublist, and the parent entry keeps a trail — pure CSS via li:has(), the script marks exactly one link.

The spy line

Where does the line sit? The module reads the first target's computed scroll-margin-top — the very value the browser uses to land anchor jumps. On this page the sections declare scroll-margin-block-start: var(--space-l), so a click on any TOC link lands the section exactly at the line and the clicked link is the one that lights up. One CSS declaration is the single source of truth for both; data-spy-offset exists only for markup where that value is unusable.

Try it: click "Concept" above, then come back via the TOC. Whatever link you choose, the highlight lands on it — no off-by-one flicker to the neighbouring section.

The end rule

A naive spy has a blind spot: a final section so short that the content ends right below it can never carry the line, so its link could never light up. The module therefore appends a zero-size, aria-hidden sentinel at the very end of the scroll content and observes it — the moment the page (or container) is scrolled to its end, the last link is current. On this page more content follows the finale below, so the blind spot is best seen in the scroll container of the second example: its last section is exactly that case.

A short finale

The last stop of this instance — its link lights up as its top edge passes the line and stays lit from there on, including at the very bottom of the page (the end rule).

One practical property is on display right here: for a section to get its moment, enough content must follow its top edge — roughly one viewport — otherwise the scroll range ends before its top can reach the line. That is why the second example and the closing notes sit below: they are this instance's tail room.

Scroll container (data-spy-root)

The second instance spies on sections inside the framed overflow: auto box below, so data-spy-root="#spyDemoBox" hands that box to the observers — the spy line now sits below the box's top edge, independent of the page scroll. These sections carry no scroll margin, so data-spy-offset="24" places the line explicitly. Note that the TOC itself lives outside the box: only the targets must sit inside the root.

Alpha

Scroll inside this box. The page may stand still — the highlight above follows the box's own scroll position, because the observers' root is the box, not the viewport.

Clicking a link scrolls the box natively; the browser knows the nearest scrollable ancestor by itself, the module never intercepts the navigation.

These sections carry no scroll margin, so the instance sets data-spy-offset="24" — the one case where the attribute earns its keep. With a margin in the CSS the attribute could simply be dropped.

Beta

The wiring is identical to the page example — anchor links, ids, one attribute for the root. Both instances on this page run completely independently; any number can coexist.

Resize the box or the window and the spy keeps working: the observers are rebuilt once per settled resize, since the spy line's geometry depends on the root's height.

Everything else behaves exactly like the page instance: the highlight follows the section whose top edge last passed the line below the box's top edge, and above the first section no link is current.

Gamma

This short last section is the end rule's blind-spot case: the box's content ends right below it, so its top edge can never reach the spy line. Scroll the box to its very end — the injected end sentinel crosses the box's bottom edge and this link lights up anyway.

Notes

Everything the two instances above need is one attribute on the TOC: data-js-behavior="scrollSpy". The boot script lazy-loads the module when it finds that attribute; the module collects the in-page anchor links, resolves their targets via the href fragments and starts observing. There is nothing to import manually and nothing to initialise by hand — dynamically inserted TOCs are wired by calling initModulesInScope() on the new subtree.

The component is behaviour-only: scrollSpy.min.css is an empty stub, and every visual on this page — the accent bar, the muted link colours, the sticky sidebar, the parent trail via li:has() — is demo CSS a consuming project would own. The single styling hook the module guarantees is aria-current="location" on exactly one link (or on none, above the first section). Misconfigured markup — a link whose target id does not exist, a duplicate link, a target outside the data-spy-root element — is reported to the console and skipped; the link then simply stays a working jump link.

A closing word on geometry, because it is the one thing every line-based spy shares: a section can only become current once its top edge reaches the spy line, and that takes roughly one viewport of content below the section's top. Where a page ends too soon after its last headings, those entries would be skipped — the end rule rescues the very last one, and tail room (like this notes block) or a larger data-spy-offset takes care of the rest. If you are reading this on a very tall screen, the readme's "tail blind spot" section explains the trade-off in detail.