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
- Why a SPA breaks the accessibility the browser gave for free
- The three reactions to a route change: title, focus, and announcement
- Updating the
document.title - Moving the focus when the view mounts
- Announcing the route with a "route announcer" (router live region)
- Restoring focus on going back and not breaking the Back button
- Skip links and routed modals in a SPA
- Lazy loading and view states
- Common mistakes and tips
- Exercises
- Conclusion
- 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]
- 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:
- Update the
document.title— so that the tab title and the reader's announcement reflect the current view. - Move the focus — to a predictable place in the new view (the
<h1>withtabindex="-1", or a region), so the keyboard user isn't left stranded. - 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.
- Updating the
document.title
document.titleThis 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.
- 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
useEffectthat 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.
- 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 |
- 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/popstatecorrectly 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.
- 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,
popstateshould close it, not navigate away.
- 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 tofalsewhen it finishes. - Show an announced loading state ("Loading your progress…") in a
statusregion, 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.titleon 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-liveon 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:
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.
Web Accessibility Course
Module 1: Introduction to Web Accessibility
- What Is Web Accessibility?
- The Importance of Web Accessibility
- Overview of Accessibility Laws and Standards
- Introduction to WCAG
Module 2: Understanding Disabilities and Assistive Technologies
Module 3: Principles of Accessible Design
- Perceivable: Making Content Available to the Senses
- Operable: User Interface and Navigation
- Understandable: Information and Operation
- Robust: Compatibility with Current and Future Technologies
Module 4: Implementing Accessibility in HTML and CSS
Module 5: Accessibility in JavaScript and Multimedia
- Creating Accessible JavaScript Widgets
- Keyboard Accessibility
- Accessible Video and Audio Content
- Providing Text Alternatives for Images
Module 6: Accessibility Testing and Evaluation
- Manual Testing Techniques
- Automated Testing Tools
- User Testing with Assistive Technologies
- Interpreting Accessibility Reports
