In 07-01 you learned to announce changes within a page. Now we go up a level: what happens when the entire application is a single page and "navigation" reloads nothing? Cursalia's student dashboard—"My courses", "Progress", "Certificates", "Settings"—is a SPA (Single Page Application): built with a client-side framework (React, Vue, or Angular—it doesn't matter which at this level), it loads a single HTML file and, when you "change pages", the client-side router rewrites the DOM without requesting a new page from the server. For Sofía or Hugo, who see the screen, the transition is smooth and fast. For Marta, using NVDA, it's a silent disaster: the screen reader doesn't announce the new page and the focus is left floating where it was. In this lesson we solve that central problem and its offshoots. We won't marry any framework: the principles are the same across all of them.

Contents

  1. Why a SPA breaks the accessibility the browser gave for free
  2. The three reactions to a route change: title, focus, and announcement
  3. Updating the document.title
  4. Moving the focus when the view mounts
  5. Announcing the route with a "route announcer" (router live region)
  6. Restoring focus on going back and not breaking the Back button
  7. Skip links and routed modals in a SPA
  8. Lazy loading and view states
  9. Common mistakes and tips
  10. Exercises
  11. Conclusion

  1. Why a SPA breaks the accessibility the browser gave for free

On a traditional (multi-page) website, every click on a link triggers a full reload. That reload, which seems like a technical detail, gave the screen reader user three things automatically:

When a page reloads, the browser… …and the screen reader user got
Changes the <title> NVDA announces the title of the new page
Resets the focus to the start of the document The focus starts in a known place, at the top
Rebuilds the entire accessibility tree The reader "knows" it's on a new page

A SPA eliminates the reload, and with it those three courtesies are lost. The client-side router changes the URL with the History API (pushState), swaps some DOM nodes… and that's it. For Marta, nothing has happened: NVDA doesn't announce the change, the focus stays on the link she clicked (which may have disappeared from the DOM), and there is no sign that she is now on "Certificates" instead of "My courses". Our job is to give back by hand what the reload gave for free.

flowchart TB
  A[User clicks a dashboard link] --> B{Traditional website<br/>or SPA?}
  B -->|Traditional| C[Reload: title, focus, and<br/>tree renew themselves]
  B -->|SPA| D[pushState: the DOM changes<br/>but nothing is announced]
  D --> E[We must, by code:<br/>1 title · 2 focus · 3 announcement]

  1. The three reactions to a route change: title, focus, and announcement

Every time the SPA's router completes a route change, we must trigger up to three reactions. Not always all three at once, but they are the repertoire:

  1. Update the document.title — so that the tab title and the reader's announcement reflect the current view.
  2. Move the focus — to a predictable place in the new view (the <h1> with tabindex="-1", or a region), so the keyboard user isn't left stranded.
  3. Announce the change — with a router live region, in case we don't move the focus or want an explicit "page loaded" message.

There are two combinable strategies: moving the focus to the heading (which also makes the reader read out the new view) or announcing with a live region without moving the focus. Many teams do both, or choose depending on the case. Let's look at them one by one.

  1. Updating the document.title

This is the easiest thing and the first you should fix. On a reload, the <title> changes by itself; in a SPA, you change it when the route resolves:

// On entering each route of the student dashboard
function onEnterRoute(route) {
  document.title = `${route.title} · Student dashboard · Cursalia`;
}
// E.g.: "Progress · Student dashboard · Cursalia"

Why it matters: many screen readers announce the document.title when it changes, and it's what the user hears to get their bearings. It also improves the browser history, bookmarks, and SEO. Put the view name first: "Progress · Cursalia" is more useful than "Cursalia · Progress" when the user has 20 tabs open.

  1. Moving the focus when the view mounts

The most recommended technique on a route change is to move the focus to the main heading of the new view. You give the <h1> a tabindex="-1" (focusable by code, outside the natural tab order) and focus it when the view finishes mounting:

<!-- Each dashboard view starts with its h1 ready to receive focus -->
<main id="view">
  <h1 tabindex="-1" id="view-title">Progress</h1>
  <!-- … view content … -->
</main>
// Called AFTER the framework has rendered the new view
function focusNewView() {
  const h1 = document.getElementById('view-title');
  h1.focus();          // NVDA reads "Progress, heading level 1"
  // Optional: scroll to the top, as a reload would
  window.scrollTo(0, 0);
}

By focusing the <h1>, two good things happen at once: Marta hears the name of the new view (because the reader announces the focused element) and Lucía has the keyboard focus at a logical point from which to start tabbing. It's the 2-in-1 solution.

Important — correct timing: focus after the framework has rendered the view. In React that would be inside a useEffect that depends on the route; in Vue, in the mounted hook; in Angular, after navigation. If you focus before the <h1> exists, nothing happens. That's why this pattern always lives in the "view already mounted" lifecycle.

  1. Announcing the route with a "route announcer" (router live region)

The alternative (or complement) to moving the focus is a route announcer: a single live region, always present in the layout, where we write the name of the new view on each navigation. It reuses everything from 07-01:

<!-- In the SPA's root layout, ALWAYS present, visually hidden -->
<div id="route-announcer" role="status" aria-live="polite" class="sr-only"></div>
/* Hidden from view, but available to the screen reader */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
// Hooked to the router's "navigation complete" event
function announceRoute(title) {
  document.getElementById('route-announcer').textContent =
    `${title}, page loaded`;
}

Notice that the region is always in the DOM from startup (we don't create it on each navigation): this is exactly the golden rule from 07-01. We change only its textContent, and since it's role="status" (polite), NVDA announces "Progress, page loaded" without interrupting.

Focus on the <h1> or route announcer? Practical guide:

Situation Recommendation
Normal navigation between dashboard views Move the focus to the <h1> (2-in-1)
You want an explicit "page loaded" message without stealing the focus Route announcer
Very complex view where focusing the h1 is disorienting Route announcer + don't move focus
Maximum robustness Both: focus the h1 and update the title

  1. Restoring focus on going back and not breaking the Back button

The browser's Back button is sacred. A well-built SPA uses the History API so that Back/Forward work as the user expects. Two rules:

  • Don't break the Back button. If you intercept navigation, use history.pushState/popstate correctly so that Back returns to the previous view, not out of the application. A Back button that "skips" steps or takes the user out of Cursalia is disorienting for everyone, and critical for those who depend on the keyboard.
  • Manage the focus when going back. When the user presses Back and returns, for example, from a course detail to the list, the ideal is to return the focus to the element they left from (the course card they opened), not to the beginning. This requires remembering the position:
// When navigating TO a detail, we remember who brought us here
let navigationOrigin = null;
courseLink.addEventListener('click', () => {
  navigationOrigin = courseLink; // save the trigger
});

// On returning (popstate) to the list, we restore the focus if we can
window.addEventListener('popstate', () => {
  // after rendering the destination view:
  if (navigationOrigin && document.contains(navigationOrigin)) {
    navigationOrigin.focus();
  } else {
    document.getElementById('view-title')?.focus(); // plan B: the h1
  }
});

It's the same "return the focus to the trigger" principle you learned with modals in Module 5, now applied to entire navigation.

  1. Skip links and routed modals in a SPA

Skip links that keep working

The "Skip to main content" link (Module 2) points to #main-content. In a SPA, since the <main> is rebuilt on each view, make sure the destination anchor still exists after each navigation and that the skip link actually moves the focus (not just the scroll):

<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- … -->
<main id="main-content" tabindex="-1">…</main>

The tabindex="-1" on the <main> guarantees that, when the skip link is activated, the focus lands there (some browsers don't focus destinations without tabindex). If your framework recreates the <main>, check that it preserves that id and that tabindex.

Routed modals

A common pattern in SPAs is for a modal to have its own route (for example, /dashboard/course/42/enroll opens the enrollment modal over the list). Combine Module 5 with this module:

  • When opening the modal (entering its route): move the focus inside the dialog and activate the focus trap (Module 5).
  • When closing it (leaving the route, including via the Back button): return the focus to the element that opened it.
  • Make sure the Back button closes the modal rather than taking the user out of the view: if the modal is a route, popstate should close it, not navigate away.

  1. Lazy loading and view states

SPAs load chunks of code and data on demand (lazy loading, code splitting). While a view loads, there is a gap in which there is no content. Apply what you learned in 07-01:

  • Mark the region being populated with aria-busy="true" and set it to false when it finishes.
  • Show an announced loading state ("Loading your progress…") in a status region, not a silent spinner.
  • When the data arrives, decide whether to move the focus to the <h1> of the now-populated view (avoid focusing content that is still a skeleton).
async function mountProgressView() {
  const main = document.getElementById('main-content');
  main.setAttribute('aria-busy', 'true');
  document.getElementById('route-announcer').textContent = 'Loading progress…';
  const data = await api.progress();      // arrives asynchronously
  renderProgress(data);
  main.setAttribute('aria-busy', 'false');
  document.getElementById('view-title').focus(); // now, focus the ready view
}

Common Mistakes and Tips

  • Not changing the document.title on navigation. The screen reader user has no way of knowing which view they're on. It's the cheapest and highest-impact fix.
  • Leaving the focus on the link that no longer exists. After navigation, the link node may have been removed from the DOM; the focus falls to the <body> and Marta is lost. Move the focus to the <h1> of the new view.
  • Creating the route announcer on each navigation. As in 07-01: the region must be in the layout from startup, present and empty. You only change its text.
  • Focusing before the view is mounted. Focusing an <h1> that doesn't exist yet does nothing. Do it in the framework's "already rendered" lifecycle.
  • Breaking the Back button. If Back takes the user out of the SPA or skips steps, you've broken a universal expectation. Use the History API carefully.
  • Putting aria-live on the entire view container. If you mark the whole <main> as a live region, every view change tries to read the entire page. Announce only a short message in a dedicated region.
  • Tip: encapsulate "change title + move focus + announce" in a single function that your router calls on every navigation. That way it's impossible to forget a step, and the whole SPA behaves the same.

Exercises

Exercise 1. Marta navigates in the student dashboard from "My courses" to "Certificates". Visually everything changes, but she perceives nothing: NVDA stays silent and her focus is on the link she clicked. List the three reactions the router should trigger when completing the route change and explain what each one solves.

Exercise 2. A colleague implements the route announcement like this. Why does Marta hear nothing, and how do you fix it?

function announce(title) {
  const div = document.createElement('div');
  div.setAttribute('role', 'status');
  div.textContent = `${title}, page loaded`;
  document.body.appendChild(div);
}

Exercise 3. In the SPA, the enrollment modal has its own route. Describe what should happen with the focus when opening it and when closing it, and what the browser's Back button should do while the modal is open.

Solutions

Solution 1. (1) Update document.title to something like "Certificates · Student dashboard · Cursalia": it gives the reader and the tab the identity of the new view. (2) Move the focus to the <h1 tabindex="-1">Certificates</h1> of the freshly mounted view: this makes NVDA read the view name and places the keyboard focus at a logical point. (3) Announce the change in the route announcer (role="status") in case the focus isn't moved or you want an explicit "page loaded". With all three, you rebuild what a reload gave for free: identity, focus, and notification.

Solution 2. Nothing is heard because the role="status" region is created and filled in the same instant (and inserted into the DOM already with text). The reader hadn't registered it as a live region. The solution is to have a single region in the layout, present from startup and empty, and simply change its text:

<div id="route-announcer" role="status" class="sr-only"></div>
function announce(title) {
  document.getElementById('route-announcer').textContent = `${title}, page loaded`;
}

Solution 3. When opening the modal (entering its route): move the focus inside the dialog (to the title or the first field) and activate the focus trap from Module 5. When closing it (leaving the route): return the focus to the element that opened it, saved as the trigger. The Back button, with the modal open, should close the modal (equivalent to leaving its route via popstate), not navigate away from the underlying view or take the user out of Cursalia. This way the modal behaves predictably with the mouse, the keyboard, and the history.

Conclusion

A SPA eliminates the page reload and, with it, three courtesies the browser gave screen readers for free: a new title, a reset focus, and a rebuilt tree. Now you know how to give them back by hand on each route change—update the document.title, move the focus to the <h1>, and/or announce with a route announcer—as well as restore the focus on going back, not break the Back button, maintain skip links and routed modals, and handle lazy loading. The thread from 07-01 is direct: the same live regions and the same focus management, now at the scale of an entire application.

With the student dashboard now accessible as a SPA, one front remains that we've been brushing against from the start: Cursalia is not in a single language, but in es/ca/en, and multilingualism has its own accessibility traps. Does the screen reader know which language to read each text in? What about text direction, dates, per-language subtitles, or translated alt text? That's what we tackle in the next lesson, 07-03: Internationalization and Localization.

© Copyright 2026. All rights reserved