Live demo: toc-scrollspy-demo.html (BEFORE/AFTER)
Baseline: a TOC that just works
- Use real headings (
<h2>,<h3>, …) with stable, uniqueidvalues. - TOC links should be normal anchors (
<a href="#section-id">). - Provide an obvious focus style on links (don’t remove outlines).
If you add a scroll spy, do these things
- Set
aria-current="true"on the active TOC link (and remove it from others). - Use redundant active styling (not color-only): e.g., caret + border + weight.
- Keep updates calm: change only when the “current section” actually changes.
- Prefer IntersectionObserver with
rootset to your scroll container (if any). Provide a throttled scroll fallback.
Focus management: choose intentionally
When a TOC link is activated, you have two reasonable options:
- No focus move: let the browser scroll, keep focus on the link. (Simple, but some keyboard users can feel “lost”.)
- Move focus to the heading: add
tabindex="-1"to headings and callheading.focus({preventScroll:true})after scrolling.
Avoid surprise focus theft on plain scroll; only move focus on explicit navigation actions.
Reduced motion
- Never force smooth scrolling when
prefers-reduced-motion: reduceis active. - If you use
scrollTo({behavior:'smooth'}), fall back toautounder reduced motion.
Sticky headers / offsets
- If headings are hidden under a sticky header, set
scroll-margin-topon headings, orscroll-padding-topon the scroll container. - Test at multiple zoom levels and on narrow viewports.
A small implementation sketch
// When active section changes:
link.setAttribute('aria-current','true');
// On TOC click (optional focus move):
content.scrollTo({ top: heading.offsetTop, behavior: reduceMotion ? 'auto' : 'smooth' });
heading.focus({ preventScroll: true });
Keep it simple. The hard part is behavior decisions (focus, motion, announcements), not the code.