Search topics
Single-page app route changes that are never announced
A single-page app's router swaps in the new view and updates the title, but leaves focus where it was, so nothing is said and the next Tab starts from the old place.
The problem
Screen reader users hear nothing when a link changes the view, and keyboard users carry on from a place that no longer leads anywhere.
A tea shop is built as a single-page app. Its header has three links: Teas, Teaware and Basket. A keyboard user presses Enter on Teaware. The address changes to /teaware, the tab title changes to "Teaware – Harbor Tea", and the teapots appear in place of the teas. A sighted mouse user sees all of it. A screen reader user hears nothing at all: focus is still on the Teaware link, and nothing on the page told the screen reader that anything moved. They press Enter again, wondering whether the link works.
Lower down, the Teas view ends with a "Shop teaware" link. Pressing that one is worse. The link belonged to the old view, so it is removed with it, and focus falls to the document itself. The next Tab starts from the top of the page in some browsers, and from roughly where the removed link used to be in others.
On an ordinary website, following a link loads a new page. The browser puts focus back at the top of the document and a screen reader reads the new page's title. A single-page app never loads a new page: its router fetches data and swaps the content in place, and everything a page load did for free is now the app's job. This router sets the title and stops there. Changing document.title is not announced by screen readers, so the change in view reaches nobody who isn't looking at it.
Two criteria apply. 2.4.3 Focus Order asks that focus moves in an order that keeps the meaning and operation of the page. Focus left on a link to a view that has already loaded, or dropped to the document when its element vanished, sends the next Tab somewhere that has nothing to do with what is now shown. 4.1.3 Status Messages asks that a change that reports the result of an action, without moving focus, can be presented to assistive technology. The new view is that result, and it is presented to nobody.
The failure is easy to miss for anyone testing with a mouse, because the screen does exactly what it should. It shows only to someone who uses the app without looking at the whole screen, or without a pointer.
3 of 10 groups affected
- No vision (affected)
- Low vision (affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Without vision Nothing is said when the view changes, so the link seems to have done nothing. They press it again, or read on from the old place and miss the new content.
- Limited manipulation Keyboard users stay on the navigation link, or, when the link was part of the old view, drop to the document, so the next Tab starts from the top or from a place that no longer exists.
- Limited vision Magnifier users zoomed in on the link don't see the content change elsewhere on the screen, and no focus ring leads them to it.
A scanner loads one URL at a time, so every view it checks arrived by a page load and passes. The failure lives only in the move from one view to the next, and no page-level check sees that. The EaseWeb audit presses one navigation link on each page and reports a lead when the address changes without a page load, focus stays put and no live region speaks; whether the result is usable is still a person's call. AI code tends to stop at setting document.title, which looks like it does the job, or to put aria-live on the whole view, which makes some screen readers read the entire page aloud and others nothing at all. Only following links with a keyboard and a screen reader shows whether a change is heard.
Try it
Tab to a link in the shop, press Enter, and listen.
Try a link in the header, then the link at the end of the list. After each, press Tab once: where does focus go next?
Broken Can you fix this?
Screen reader says:
(Press Enter on a link)
The fix
After each route change, move focus to the new view's heading, or to the top of the page with a spoken page name.
- 1After each route change, move focus on purpose: to the new heading, or to the top with an announcement.
- 2Say the new view's name once, through focus or a status region that was there from the start.
- 3Leave focus alone on the first load and on changes within one view.
<body> <nav> <a href="/teas">Teas</a> <a href="/teaware">Teaware</a> <a href="/basket">Basket</a> </nav> <main id="view"> <h1>Teaware</h1> … </main></body>Focus the new view's heading
<body> <nav> <a href="/teas">Teas</a> <a href="/teaware">Teaware</a> <a href="/basket">Basket</a> </nav> <main id="view"> <h1 tabindex="-1">Teaware</h1> … </main></body>The tab title is right, but a screen reader does not read a title change, and focus stays on the link that was pressed, or falls to the document when that link went with the old view.
Moving focus to the heading does both jobs at once. The screen reader reads it as it receives focus ("Teaware, heading level 1"), and the next Tab goes to the first link or button in the new view. tabindex="-1" lets the script focus the heading without adding it to the Tab order. On the first load the browser has already put focus at the top of the page, so the router leaves it there.
Both pass 2.4.3 and 4.1.3: the first by moving focus to something that names the new view, the second by resetting focus and putting the name in a status message. Focusing the heading takes people straight to the new content, and in Gatsby's user testing it was the clearest option for screen reader users, while a reset to the top felt like a lot to get through in a large app. The reset feels like an ordinary page load and needs no heading to exist on every view, and some frameworks already do it for you: SvelteKit moves focus to the body and reads the new title from its own status region. If your framework does the reset, keep it and check that each title names its view. If you write the routing yourself, focusing the heading is the shorter change.
Not every URL change is a new view. A filter, a sort order, a page of results or a tab often writes to the address too. Moving focus there would throw people out of the control they were using. Keep focus where it is and announce the result instead, as in search and filter results that update without a word. Move focus only when the main content has been replaced.
Focus the heading, not the wrapper. Some apps focus main or the view's container. Screen readers then read anything from "main" alone to the whole view at once. The h1 gives a short, known announcement. Every view needs one for this fix, which it should have anyway.
Keep the focus ring. A focused heading may show the browser's focus ring. Don't remove it: magnifier users rely on it to find where they have landed. A quieter ring style for headings is fine. Scroll the focused element into view as well, and restore the scroll position the router keeps on Back.
Back and Forward. Handle them the same way as a link. Some apps put focus back on the link that led away from the view; that is a good extra, not a substitute.
Data that arrives later. If the view shows a loading state first, move focus or announce once the heading is there, and say that it is loading in a status region meanwhile, as loading spinners that screen readers never announce describes.
Not fixes on their own.
- Changing
document.title: the tab is right, but nothing is read. It is still needed, for page titles that are the same on every page. aria-liveon the whole view: some screen readers read everything in it, others nothing, and all of it repeats on each change.- A status region with focus left where it was: the name is read, and focus on a navigation link still makes sense, but when the link pressed was part of the old view, focus is lost with it.
autofocuson the heading: browsers apply it once per document, not on each route change.
Fixing this with an AI coding assistant? Get this guide as Markdown
Verify the fix
5 checks, no mouse.
Test with: Keyboard, Screen reader, Zoom
Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat when the router or the layout changes.
Limits
This is a starting pattern, not a guarantee for every application. Screen readers react differently to focus on a heading and to focus on the body, so test with the ones your users use. This guide covers views that replace the main content. Dialogs, drawers and in-page updates have their own patterns. Test the complete journey on your real routes, including deep links and slow networks.
This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.
References
- WCAG Understanding: focus-order (opens in a new tab)
- WCAG Understanding: status-messages (opens in a new tab)
- gatsbyjs.com: 2019-07-11-user-testing-accessible-client-routing (opens in a new tab)
- svelte.dev: accessibility (opens in a new tab)
- nextjs.org: accessibility (opens in a new tab)
- developer.mozilla.org: tabindex (opens in a new tab)
Keep this fixed as your code changes.
Turn guides like this into rules your coding agents and CI follow, then have the result retested with a keyboard and screen readers.