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.