The sticky sidebar TOC with the highlighted current section is table stakes for docs and long reads. The common build is a scroll listener comparing getBoundingClientRect() for every heading on every scroll frame — layout thrash, on the main thread, for information the browser already has.
Velocity X's table-of-contents does it the free way: one IntersectionObserver watching the headings, zero scroll listeners, zero per-frame work.
The Money Pattern
useEffect(() => {
const headings = document.querySelectorAll('article h2, article h3');
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) setActive(e.target.id);
}
},
// The magic: a band near the top of the viewport, not the whole screen
{ rootMargin: '-10% 0px -80% 0px' }
);
headings.forEach((h) => observer.observe(h));
return () => observer.disconnect();
}, []);
That rootMargin is the whole trick. It shrinks the observation zone to a thin band 10–20% from the viewport top, so "intersecting" means "this heading just became the one you're reading" — not "this heading is somewhere on screen". Without it, three sections register at once and the highlight jitters.
The Catch
Short final sections break the naive version: a 200px conclusion never reaches the band, so the TOC never highlights it even at full scroll. The fix is one special case — at scroll bottom, force the last heading active.
Accessibility is the other skipped step. The TOC is navigation, so it's a <nav aria-label="On this page">, and the active link gets aria-current="true" — not just a colour change, which screen readers can't see and colour-blind users can barely see.
The Verdict
Scroll listeners doing rect math is 2015 code. One observer, one rootMargin band, one bottom-of-page special case — smoother highlight, zero main-thread cost. The finished version sits in Velocity X's Long-form category with the pull quotes, reading-time and callout components that make article pages feel finished.









