← Back to the library
FrontendApplied · 7 min

Keyboard and screen-reader users get lost after navigation or a modal

“After clicking a link in the SPA, a screen reader says nothing and Tab starts from the old position or the top of the body; with a modal open, Tab walks into the page behind it, and closing it drops focus on <body>.”

THE PRINCIPLE

Focus is the keyboard user's cursor and the screen reader's reading position. Every view change must leave it somewhere meaningful, and every modal must hold it and give it back.

FIRST MOVES

  1. Test with the keyboard only and with a screen reader (VoiceOver, NVDA): navigate, open and close every dialog, and note where focus lands each time.
  2. On route change, update document.title and move focus to the new page's h1 (tabindex="-1") or a small skip link, plus a polite live-region announcement; check whether your router already announces.
  3. Use <dialog> with showModal(): it uses the top layer, makes the rest of the page inert, closes on Escape, and focuses the first focusable element or the one with autofocus.
  4. For custom overlays, set inert on the background instead of hand-written focus traps and aria-hidden juggling.
  5. On close, return focus to the element that opened the dialog, or to the next logical step if that element is gone.

TOOLS: THEN → NOW

JavaScript focus-trap libraries and aria-hidden on siblings<dialog>.showModal() and the inert attribute, both Baseline
No announcement on client-side navigationFramework route announcers (Next.js reads title, then h1, then path) or a live region you own

PATTERN SNAPSHOT

// After the router renders the new page.
function onRouteChange(title: string) {
  document.title = title + " · Acme";
  const heading = document.querySelector<HTMLElement>("main h1");
  heading?.setAttribute("tabindex", "-1");
  heading?.focus({ preventScroll: false });
  announcer.textContent = title + " page"; // <div aria-live="polite">
}

// Modal: the browser handles inertness, Escape, and the top layer.
const opener = document.activeElement as HTMLElement | null;
dialog.showModal();
dialog.addEventListener("close", () => opener?.focus(), { once: true });

CLOSE THE AI. EXPLAIN THIS.

Why is focusing <body> or the app root after a route change worse than focusing the new h1, and what does showModal() give you that a div with role="dialog" does not?

HOW IT WORKS UNDERNEATH

SOURCES

Guide reviewed