QuestionsAccessibility

Skip links & focus management in SPAs

Focus ManagementMediumAccessibility

What is a skip link and why does it matter? How do you handle focus when a client-side route changes?

What it tests

Bypassing repeated content and the SPA-specific problem of route changes not moving focus.

Approach & answer

A SKIP LINK is a link, first in the tab order, that jumps past repeated blocks (nav, header) straight to <main>. Keyboard and screen-reader users otherwise Tab through the same 30 nav links on every page. Convention: it's visually hidden until focused, then appears; it targets an id on the main region (href="#main"), which should have tabindex="-1" so focus actually lands there in all browsers. This satisfies WCAG 2.4.1 (Bypass Blocks). The SPA problem is subtler and widely broken: with a real page navigation the browser resets focus to the top and screen readers announce the new page. With CLIENT-SIDE routing, the URL and DOM change but focus stays on the link the user clicked and NOTHING is announced — a screen-reader user has no idea the page changed, and a keyboard user's next Tab resumes from a stale location. The fix on each route change: move focus to a sensible target — typically the new page's <h1> (given tabindex="-1") or the main container — and/or announce the new page title via a polite live region (a visually-hidden aria-live region updated with the route name). Also update document.title so the tab/announcement reflects the page. Frameworks don't do this automatically; it's the app's responsibility. The senior framing: an SPA must recreate the two things a full navigation gave for free — focus reset and a page-change announcement.

Use this technique when

Adding a skip link; fixing SPA route changes that leave focus stranded and unannounced.

Code

// Skip link (first focusable element on the page)
// <a class="skip-link" href="#main">Skip to content</a>
// <main id="main" tabindex="-1">…</main>

// On every client-side route change, move focus + announce:
function onRouteChange(pageTitle) {
  document.title = pageTitle;                 // reflect the new page
  const h1 = document.querySelector('main h1');
  if (h1) { h1.setAttribute('tabindex', '-1'); h1.focus(); }  // focus lands on new content
  announce(pageTitle);                        // update a polite aria-live region
}

// Visually-hidden live region kept in the DOM:
// <div aria-live="polite" class="sr-only" id="route-status"></div>
function announce(msg){ document.getElementById('route-status').textContent = msg; }

References