Route changes in SPAs
Click a link in a single-page app and the URL changes — but for a screen reader user, nothing happened. Here's how to tell them.
In a multi-page app, clicking a link is a full document navigation — the browser tears down the old page and builds the new one. Screen readers know this happened: they announce the new title, focus resets to the top of the document, and assistive tech recalculates everything from scratch. In a single-page app (SPA), clicking a link just runs JavaScript that swaps DOM nodes. The URL changes, the visuals change, and the screen reader has no idea anything happened. From its perspective the user is still standing on the same page.
Fixing this isn't optional. SPAs ship to the same users that MPAs do, and screen reader users navigate by landmark, heading, and announcement. If none of those change on route navigation, they're stuck.
What MPAs do for free
When a real page load happens, the browser:
- Updates the document title (announced as the new page name).
- Resets focus to the document body.
- Re-reads landmarks and headings from scratch.
In an SPA, none of this happens automatically. You have to do all three jobs by hand on every route change.
Announce the new page
Two solid patterns. Pick one per app — mixing is just confusing.
1. Live region announcement. Have a single hidden polite live region somewhere in the layout (role="status" and visually hidden). On route change, set its text to something like "Settings page".
<div id="route-announcer" role="status" aria-live="polite" class="sr-only" ></div>
router.on("change", (newRoute) => {
document.getElementById("route-announcer").textContent =
`${newRoute.title} page`;
});The user hears "Settings page" without anything visual changing for sighted users.
2. Move focus to a heading. The new view's <h1> gets tabindex="-1" and you call .focus() on it. Screen reader announces "Settings, heading level one." Sighted keyboard users continue from a sensible place.
router.on("change", () => {
const h1 = document.querySelector("main h1");
if (h1) {
h1.setAttribute("tabindex", "-1");
h1.focus();
}
});The focus-the-heading pattern is what most modern frameworks ship by default. It addresses both the announcement and the focus-reset jobs at once.
Either pattern is correct. The test: navigate to a new view with a screen reader running and ears closed. If you can hear the page changed, you're done.
Where focus goes after navigation
This is the one most SPAs get wrong. After route change, where is keyboard focus?
- If you do nothing, focus stays on whatever the user just clicked — usually a link in a nav menu. Tab goes back into the nav, not into the new page.
- Move it to the new page's main heading or main landmark. The user's next Tab lands them in the content they navigated to.
For accessibility plus usability, focus should move into the new view's content. A common compromise: focus a <main> element with tabindex="-1", so the screen reader announces something neutral like "main" rather than barking the heading text on every navigation.
router.on("change", () => {
const main = document.querySelector("main");
main?.setAttribute("tabindex", "-1");
main?.focus();
});Don't focus the document body — focus moves there by default and assistive tech mostly ignores it. You need a focusable element inside the new view.
The back-button contract
When the user presses Back, they expect to land where they were and at the same scroll position. SPAs have to opt into this. Two parts:
history.scrollRestoration = "auto"— the browser tries to restore scroll. Often enough.- Save & restore manually when the layout has shifted (lazy-loaded images, virtualized lists). Capture
scrollYwhen leaving a route, restore it when entering on a popstate event.
The accessibility specific: when the user goes back to a list page, did they tab into the detail page from a specific row? Returning focus to that row, not to the page top, makes Back actually feel like Back.
// Before navigating away from list, record the row id you came from
sessionStorage.setItem("returnFocusId", clickedRow.id);
// On returning to the list page
window.addEventListener("popstate", () => {
const id = sessionStorage.getItem("returnFocusId");
document.getElementById(id)?.focus();
sessionStorage.removeItem("returnFocusId");
});This is fiddly. It's also the difference between "back works" and "back is awful" on a 200-item list.
Don't forget the document title
document.title is what screen readers announce when a real page load happens. It's still the most reliable cross-software cue, even with all the live-region work. Update it on every route change.
router.on("change", (newRoute) => {
document.title = `${newRoute.title} — Acme`;
});Frameworks tend to do this for you, but verify: in browser DevTools, watch the title bar as you click around. If it doesn't change, your tab title is stale and your accessibility story is broken.
Try it yourself
document.title on SPA navigation?