# Single-page app route changes that are never announced

Source: https://easeweb.dev/learn/route-changes
Topics: Live updates > Moving to a new view in a single-page app is announced (WCAG 2.4.3, 4.1.3)
The fix: After every client-side route change, move focus on purpose. Either focus the new view's h1, given tabindex="-1", so the screen reader reads it and the next Tab goes into the new content, or reset focus to the top of the document, as a page load does, and write the new page's name into a status region that was there from the start.
Test with: keyboard, screen reader, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/ https://svelte.dev/docs/kit/accessibility https://nextjs.org/docs/architecture/accessibility https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex

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.

## What happens

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.

## Why it fails

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.

## Who is affected

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.

## What automation and AI miss

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.

## Before: the router sets the title and nothing else

```html
<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>
```

```js
router.afterEach(route => {
  document.title = `${route.meta.title} – Harbor Tea`;
  // #view re-renders; focus stays put
});
```

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.

## After: focus the new view's heading

```html
<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>
```

```js
let firstLoad = true;

router.afterEach(async route => {
  document.title = `${route.meta.title} – Harbor Tea`;
  if (firstLoad) {
    firstLoad = false;
    return;
  }
  // Wait for the view to render
  // (nextTick in Vue, tick() in Svelte).
  await viewRendered();
  document.querySelector('#view h1').focus();
});
```

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.

## After: start again from the top, and announce the page

```html
<body>
  <a class="skip-link" href="#view">Skip to content</a>
  <nav>
    <a href="/teas">Teas</a>
    <a href="/teaware">Teaware</a>
    <a href="/basket">Basket</a>
  </nav>
  <main id="view">
    <h1>Teaware</h1>
    …
  </main>
  <p id="announcer" role="status" class="sr-only"></p>
</body>
```

```js
const announcer = document.getElementById('announcer');
let firstLoad = true;

router.afterEach(async route => {
  document.title = `${route.meta.title} – Harbor Tea`;
  if (firstLoad) {
    firstLoad = false;
    return;
  }
  await viewRendered();
  // Back to the top, as a page load does.
  document.body.tabIndex = -1;
  document.body.focus();
  document.body.removeAttribute('tabindex');
  announcer.textContent = document.title;
});
```

This copies what the browser does on a real page load: focus returns to the top, so the next Tab reaches the skip link, and the status region, which is in the layout from the start and outside the part the router replaces, says the new page's name. The `body` takes `tabindex="-1"` only for as long as it takes to focus it. Keyboard users now Tab through the header again after every change, as they would on a multi-page site, so keep a working skip link at the top.

## Which option to use

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.

## Implementation decisions

**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](/learn/search-results). 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](/learn/loading-states) 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](/learn/page-title).
- `aria-live` on 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.
- `autofocus` on the heading: browsers apply it once per document, not on each route change.

## Verify the fix

1. In developer tools, follow a link that changes the view and check `document.activeElement`: it is the new view's heading, or the `body` with a status region that was there before the change now holding the new name. It is never the old link or a removed element.
2. With only a keyboard, follow links in the navigation and inside the content. After each, press Tab: focus goes into the new view, or to the skip link at the top.
3. With a screen reader, follow the same links. The new view's name is read once after each, and the first page load does not read it twice.
4. Use Back and Forward, then change a filter or a sort order. Back and Forward behave like links; the filter keeps focus on its control and announces the result.
5. At 200% zoom or with a magnifier, follow a link and check that where focus lands is scrolled into view, with its ring visible.

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.
