A screen-reader-friendly scroll spy exposes the active link with aria-current="true" (not colour alone), never announces changes as the user scrolls, and makes each link move keyboard focus to its section heading so activation is perceivable and the next Tab continues from there.

Problem / Scenario Context

A long API reference has a sticky table of contents on the left. An IntersectionObserver, built as in scroll spy navigation, adds an .active class to the link for the section in view, which turns it crimson. An accessibility review finds three issues: the active state is conveyed only by colour, the links use JavaScript smooth scrolling that moves the viewport but leaves focus in the sidebar, and an earlier attempt to fix the first issue added a live region that announced "Now reading: Parameters" every time a heading crossed — which screen-reader users described as unbearable.

Each of these has a straightforward fix. They sit under the broader guidance in Accessible Observer-Driven Interfaces.

Mechanics Explanation

State without colour. WCAG 1.4.1 (Use of Color) disallows colour as the only means of conveying information. A visual indicator such as an underline, a bar or a weight change solves the visual side; aria-current solves the programmatic side. aria-current="true" (or "location") on a link tells screen readers "this is the current item in a set", and they announce it when the user reaches the link — "Parameters, current, link".

State without chatter. Scroll spy state changes continuously while scrolling. It is available information, not urgent information. Putting it in a live region pushes it at the user on every crossing; leaving it on the link lets them pull it when they visit the navigation.

Activation moves focus. A same-page link (href="#parameters") natively scrolls to the target and — in all current browsers — moves the sequential focus navigation starting point there, so the next Tab continues from the section. Replacing it with event.preventDefault() plus scrollIntoView({ behavior: 'smooth' }) loses that. The fix is to let the fragment navigation happen, or to replicate it by focusing the heading.

Scroll Spy State: Pushed Versus AvailableTwo columns. Pushing the active section into a live region announces every heading crossing while the user scrolls, interrupting reading. Exposing it with aria-current on the link keeps it silent until the user reaches the table of contents, where the link is announced as current.Pushed via a live regionAnnounces on every heading crossingInterrupts whatever is being readFloods users who scroll quicklyAvailable via aria-currentSilent while scrollingAnnounced as "current" when the user visits the linkAlso works for voice control and switch users

Comparison Table: Indicator Options

Indicator Visible without colour? Exposed to AT? Announced while scrolling?
.active { color: crimson } no no no
Left border or underline + colour yes no no
aria-current="true" + visual indicator yes yes no
Live region "Now reading…" n/a yes yes — too much
aria-selected wrong role semantics no

Minimal Reproducible Example

TypeScript
// Colour-only state and focus-losing smooth scroll
toc.addEventListener('click', (ev) => {
  const a = (ev.target as HTMLElement).closest('a');
  if (!a) return;
  ev.preventDefault();
  document.querySelector(a.hash)!.scrollIntoView({ behavior: 'smooth' });
});
new IntersectionObserver((entries) => {
  for (const e of entries) if (e.isIntersecting)
    toc.querySelectorAll('a').forEach((l) => l.classList.toggle('active', l.hash === `#${e.target.id}`));
}, { rootMargin: '0px 0px -70% 0px' }).observe(...document.querySelectorAll('h2'));

Activate a link with the keyboard and press Tab: focus continues inside the table of contents, not in the section you jumped to.

Production-Safe Solution

TypeScript
export function accessibleScrollSpy(toc: HTMLElement, headings: HTMLElement[]): () => void {
  const links = new Map<string, HTMLAnchorElement>();
  toc.querySelectorAll<HTMLAnchorElement>('a[href^="#"]').forEach((a) => links.set(a.hash.slice(1), a));

  let current: HTMLAnchorElement | null = null;
  const setCurrent = (id: string): void => {
    const next = links.get(id) ?? null;
    if (next === current) return;
    current?.removeAttribute('aria-current');
    next?.setAttribute('aria-current', 'true');        // programmatic state, no announcement
    current = next;
  };

  const io = new IntersectionObserver((entries) => {
    const visible = entries.filter((e) => e.isIntersecting)
      .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
    if (visible[0]) setCurrent(visible[0].target.id);
  }, { rootMargin: '0px 0px -70% 0px' });
  headings.forEach((h) => io.observe(h));

  // Keep native fragment navigation (it moves the focus starting point),
  // and additionally focus the heading so screen readers read it.
  const onClick = (ev: MouseEvent): void => {
    const a = (ev.target as HTMLElement).closest<HTMLAnchorElement>('a[href^="#"]');
    if (!a) return;
    const target = document.getElementById(a.hash.slice(1));
    if (!target) return;
    if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
    // Let the browser scroll (CSS scroll-behavior handles smoothness), then focus.
    requestAnimationFrame(() => target.focus({ preventScroll: true }));
    setCurrent(target.id);                              // don't wait for the observer
  };
  toc.addEventListener('click', onClick);

  return () => { io.disconnect(); toc.removeEventListener('click', onClick); };
}
CSS
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

.toc a[aria-current="true"] {
  font-weight: 600;
  border-inline-start: 3px solid currentColor;        /* not colour alone */
  padding-inline-start: 0.5rem;
}
h2[tabindex="-1"]:focus { outline: none; }             /* programmatic focus target */
h2[tabindex="-1"]:focus-visible { outline: 2px solid; }

Styling off the ARIA attribute rather than a separate class guarantees that the visual state and the programmatic state can never drift apart. Setting the current link on click, without waiting for the observer, avoids the highlight flickering through every intermediate section during the smooth scroll.

Activating a Table-of-Contents LinkFour steps. The user activates a link with keyboard or pointer. The browser performs native fragment navigation, scrolling to the section and moving the focus starting point. The script focuses the heading without scrolling so screen readers read it. The link is marked current immediately, and the observer keeps it correct as the user scrolls on.1Activate linkEnter key, click, voice command or switch.2Native fragment navigationScrolls to the section and moves the focus starting point.3Focus the headingtabindex=-1 and focus({ preventScroll: true }); it is read aloud.4Mark currentaria-current set now; the observer maintains it afterwards.

Handling Sections Shorter Than the Viewport

Scroll spies misbehave at the end of a document: the last few sections are too short to ever reach the top 30% of the viewport, so their links never become current, and a user who activates "Changelog" sees "Examples" still highlighted. That is a correctness bug for screen-reader users too, because aria-current then points at the wrong link.

Two fixes work well together. Setting the current link on activation (as above) makes explicit navigation always correct. For passive scrolling, add a sentinel at the very end of the content and, when it intersects, mark the last heading that is on screen as current. The scroll spy guide covers the geometry; accessibility only adds that the attribute must follow the same rule as the visual highlight.

There are other details worth getting right while you are there:

Mobile disclosure. On narrow screens the table of contents often collapses into a disclosure button. The button should be a real <button> with aria-expanded, and its label can include the current section ("On this page: Parameters") so the state is available even when the list is collapsed. Update that label from the same setCurrent function.

Nested headings. For tables of contents with h2 and h3 levels, mark only the most specific visible link as current. Marking both the parent and child makes the screen reader announce "current" twice in a row and confuses what "current" means.

Voice control. Users of voice control say the visible text of a link ("click Parameters"). Keep link text identical to the heading text so the command matches, and do not truncate it visually with ellipses that change the accessible name.

Short Final Sections Never Reach the Trigger BandA viewport scrolled to the end of the document with a root margin shrinking the trigger band to the top part of the screen. The Examples heading is inside the band and marked current. The shorter Changelog and License headings sit below the band and can never scroll up into it, so they would never become current without an end-of-content sentinel.Examples heading — in the band, marked currentChangelog heading — can never reach the bandLicense heading — same problemSolid blue frame: viewport (root). Dashed frame: rootMargin -40px.An end-of-content sentinel marks the last visible heading current once the page cannot scroll further.

Verification Steps

  • Navigate the table of contents with a screen reader; the active link should be announced as "current".
  • Activate a link with Enter and press Tab; focus should move to the first focusable element in that section.
  • Scroll with a screen reader running and confirm nothing is announced as sections change.
  • Check in forced-colors mode (Windows High Contrast); the border indicator should still show the active link.
  • Emulate reduced motion and confirm link activation jumps without smooth scrolling.

Common Mistakes to Avoid

  • Using aria-selected or aria-pressed. Those belong to tabs, options and toggle buttons; links use aria-current.
  • Announcing section changes. Scroll position is not a status message.
  • Preventing default on fragment links. It breaks the focus starting point, back-button history and middle-click.
  • Focusing the heading without preventScroll. It can abort the smooth scroll halfway.

FAQ

Should aria-current be "true", "location" or "page"?

"page" is for the current page in site navigation. For links to sections within the current page, "true" or "location" both work; "true" has the widest support in screen readers.

Does smooth scrolling hurt accessibility?

It can trigger discomfort for motion-sensitive users, so disable it under prefers-reduced-motion. It does not affect focus as long as native fragment navigation still happens.

Why add tabindex="-1" to headings?

Headings are not focusable by default. tabindex="-1" makes them programmatically focusable without adding them to the Tab order, so the script can move focus there after navigation.

Is the sticky table of contents itself a problem for screen readers?

Not if it is a nav landmark with a label, such as aria-label="On this page". Sticky positioning is purely visual; the reading order is unaffected.


↑ Back to Accessible Observer-Driven Interfaces