// guide
Accessibility for Single Page Applications (SPAs)
Single page applications replace content dynamically without full page reloads. This creates fundamental accessibility problems: screen readers don't know when content has changed, focus gets lost after navigation, the browser doesn't announce new page titles, and the back button may not work as expected. This guide covers the techniques you need to make SPAs accessible, from route announcements and focus management to dynamic content updates and error handling.
// 01 · why spas break accessibility
Why SPAs Break Accessibility
In a traditional multi-page website, every navigation triggers a full page reload. The browser announces the new page title, moves focus to the top of the page, and the screen reader starts reading from the beginning. Users know where they are and what happened.
SPAs eliminate the full page reload. Instead, JavaScript intercepts link clicks, fetches new data, and swaps content in the DOM. This is great for performance, but it breaks the implicit contract between the browser and assistive technology. Here are the specific problems:
- No automatic announcements. When a SPA swaps content, the screen reader has no idea anything changed. The user clicks a link, hears nothing, and has no way of knowing that the page content is now completely different.
- Focus stays on old content. After a route change, focus remains wherever it was before navigation — often on a link or button that no longer exists in the DOM. This leaves keyboard and screen reader users stranded.
- Screen readers don't know content changed. Without a page reload signal, the screen reader's virtual buffer may still contain the old page content. Users may navigate through stale information until the buffer refreshes.
- Browser history issues. If the SPA doesn't properly manage the History API, the back and forward buttons may not work as expected. Users lose a fundamental navigation mechanism they rely on.
// 02 · route change announcements
Route Change Announcements
When a SPA navigates to a new route, you must explicitly announce the change to screen reader users. There are two primary approaches: using an aria-live region to announce the new page title, or moving focus to the new page heading.
Approach 1: Visually Hidden Live Region
Create a visually hidden aria-live region that exists in the DOM at all times. After each route change, update its text content with the new page title. The screen reader will announce the change automatically.
<!-- Add this once, near the top of the body -->
<div
id="route-announcer"
role="status"
aria-live="polite"
aria-atomic="true"
class="visually-hidden"
></div>
// After each route change, update the announcer
function announceRouteChange(pageTitle) {
const announcer = document.getElementById('route-announcer');
announcer.textContent = '';
// Brief delay ensures the screen reader
// detects the content change
requestAnimationFrame(() => {
announcer.textContent = pageTitle + ' — page loaded';
});
}
Approach 2: Move Focus to the New Page Heading
After the route changes and new content renders, move focus to the <h1> of the new page. This causes the screen reader to announce the heading, giving the user immediate context about where they are.
function focusNewPageHeading() {
// Wait for the new content to render
requestAnimationFrame(() => {
const heading = document.querySelector('h1');
if (heading) {
// Make the heading programmatically focusable
// without adding it to the tab order
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
}
Framework-Agnostic Vanilla JS Example
This example combines both approaches and integrates with the History API for a complete route change handler:
class SPARouter {
constructor() {
this.announcer = this.createAnnouncer();
window.addEventListener('popstate', () => this.handleRouteChange());
}
createAnnouncer() {
const el = document.createElement('div');
el.id = 'route-announcer';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.setAttribute('aria-atomic', 'true');
el.classList.add('visually-hidden');
document.body.prepend(el);
return el;
}
navigate(url) {
history.pushState({}, '', url);
this.handleRouteChange();
}
handleRouteChange() {
// 1. Load and render the new content
this.renderContent();
// 2. Update the document title
document.title = this.getPageTitle();
// 3. Announce the route change
this.announcer.textContent = '';
requestAnimationFrame(() => {
this.announcer.textContent =
document.title + ' — page loaded';
});
// 4. Move focus to the new h1
requestAnimationFrame(() => {
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
}
}
react-router's built-in announcer or the @reach/router package which handles focus management automatically. In Vue, use vue-announcer to broadcast route changes to screen readers. In Angular, use the LiveAnnouncer from @angular/cdk/a11y to programmatically announce route transitions. These libraries handle edge cases like timing and buffer clearing that are easy to get wrong in a custom implementation.
// 03 · focus management on navigation
Focus Management on Navigation
After a route change in a SPA, you need to decide where focus should go. There are three common approaches, each with trade-offs:
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
Focus the <h1> |
Set tabindex="-1" on the new page heading and call .focus() |
Screen reader announces the page title immediately. Users know where they are. | Requires every page to have an <h1>. The heading receives a focus outline unless you use :focus:not(:focus-visible) to suppress it. |
| Focus the skip link target | Move focus to the <main> element or a skip link target at the top of the new content |
Consistent focus position. Users can immediately tab into the new content. | Screen reader may not announce anything meaningful — the <main> element has no visible label. Pair with a live region announcement. |
| Focus a live region | Move focus to a visually hidden live region that announces the page change | Full control over the announcement text. Works even if the page has no heading. | Focus is on a hidden element, which can be confusing. User must tab to reach visible content. |
For most SPAs, focusing the <h1> is the recommended approach. It provides immediate context and mimics the experience of a full page reload, where the browser starts reading from the top of the page.
function manageFocusAfterNavigation() {
requestAnimationFrame(() => {
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: false });
// Remove tabindex after blur so the heading
// doesn't appear in the tab order permanently
heading.addEventListener('blur', () => {
heading.removeAttribute('tabindex');
}, { once: true });
}
});
}
:focus:not(:focus-visible) selector to suppress the outline for programmatic focus while keeping it visible for keyboard focus: h1:focus:not(:focus-visible) { outline: none; }
// 04 · dynamic content updates
Dynamic Content Updates
SPAs frequently update parts of the page without a full route change — loading new data, showing search results, appending items to a list, or displaying skeleton screens while content loads. Each of these updates needs to be communicated to assistive technology.
Loading States
Use aria-busy="true" on the container that is loading. This tells screen readers that the region is being updated and they should wait before announcing changes. Set it to false when loading completes.
<div id="results" role="region" aria-label="Search results" aria-busy="false">
<!-- Results appear here -->
</div>
<!-- Accessible loading spinner -->
<div id="loading-spinner" role="status" class="visually-hidden">
Loading results...
</div>
async function loadResults(query) {
const resultsContainer = document.getElementById('results');
const spinner = document.getElementById('loading-spinner');
// Signal that the region is updating
resultsContainer.setAttribute('aria-busy', 'true');
// Show the loading announcement
spinner.textContent = 'Loading results...';
spinner.classList.remove('visually-hidden');
try {
const data = await fetchResults(query);
// Render the new content
resultsContainer.innerHTML = renderResults(data);
// Announce the result count
spinner.textContent =
data.length + ' results found for "' + query + '"';
} catch (error) {
spinner.textContent = 'Error loading results.';
} finally {
// Signal that loading is complete
resultsContainer.setAttribute('aria-busy', 'false');
}
}
When to Use aria-live="polite" vs "assertive"
polite— Use for most updates. The screen reader will finish its current announcement before reading the new content. Use for search results, status messages, items added to a cart, and non-critical notifications.assertive— Use sparingly. The screen reader interrupts whatever it is currently announcing to read the new content immediately. Reserve for genuine errors, time-sensitive alerts, and session expiration warnings.
// 05 · client-side error handling
Client-Side Error Handling
In traditional websites, error pages (404, 500) are server-rendered with distinct URLs and page titles. In SPAs, errors are rendered client-side, which means you must handle the announcement and focus management yourself.
404 Pages in SPAs
When the router can't match a URL, render a 404 page and treat it like any other route change — update the document title, announce the change, and move focus to the heading.
function render404Page() {
document.title = 'Page not found — MySite';
const main = document.getElementById('main-content');
main.innerHTML = `
<h1 tabindex="-1">Page Not Found</h1>
<p>The page you requested does not exist.</p>
<a href="/">Go to the home page</a>
`;
// Focus the heading so screen readers
// announce the error
const heading = main.querySelector('h1');
heading.focus();
// Also announce via the live region
announceRouteChange('Page not found');
}
Form Validation After Async Submission
When a form is submitted asynchronously and the server returns validation errors, the errors must be announced to screen reader users and focus must be managed appropriately.
async function handleFormSubmit(form) {
const response = await submitForm(form);
if (!response.ok) {
const errors = await response.json();
// Create or update the error summary
const summary = document.getElementById('error-summary');
summary.innerHTML = `
<h2>There were ${errors.length} errors
with your submission</h2>
<ul>
${errors.map(err =>
`<li><a href="#${err.field}">
${err.message}
</a></li>`
).join('')}
</ul>
`;
// Mark individual fields as invalid
errors.forEach(err => {
const field = document.getElementById(err.field);
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby',
err.field + '-error');
});
// Move focus to the error summary
summary.setAttribute('tabindex', '-1');
summary.focus();
}
}
Network Error Announcements
When a network request fails, announce the error using an aria-live="assertive" region so the user is immediately informed.
<!-- Persistent assertive live region for errors -->
<div
id="error-announcer"
role="alert"
aria-live="assertive"
class="visually-hidden"
></div>
function announceError(message) {
const announcer = document.getElementById('error-announcer');
announcer.textContent = '';
requestAnimationFrame(() => {
announcer.textContent = message;
});
}
// Usage
try {
await fetchData();
} catch (error) {
announceError(
'Network error: unable to load data. '
+ 'Please check your connection and try again.'
);
}
// 06 · document title updates
Document Title Updates
Updating document.title on every route change is one of the simplest and most important things you can do for SPA accessibility. The document title serves multiple purposes:
- Screen reader users rely on the page title to identify which tab or window they are in. When using Alt+Tab or the screen reader's window list, the title is the primary identifier.
- Browser tabs display the page title, helping all users find the right tab when multiple tabs are open.
- Browser history entries use the page title. Without updates, every history entry shows the same title, making the back button useless for identifying previous pages.
- Bookmarks default to the page title. If you don't update it, bookmarked SPA pages all have the same name.
// Simple utility to update the document title
// with a consistent format
function updateDocumentTitle(pageTitle, siteName) {
siteName = siteName || 'MySite';
if (pageTitle) {
document.title = pageTitle + ' — ' + siteName;
} else {
document.title = siteName;
}
}
// Call on every route change
router.onRouteChange((route) => {
updateDocumentTitle(route.title);
});
// 07 · testing spa accessibility
Testing SPA Accessibility
Testing SPA accessibility requires going beyond automated checks. The dynamic nature of SPAs means that many issues only surface during interaction. Here are specific strategies for testing SPA accessibility:
Test with a Screen Reader During Navigation
The most important test is to navigate your SPA with a screen reader running. Use VoiceOver on macOS, NVDA or JAWS on Windows, or TalkBack on Android. Navigate between routes and verify that each route change is announced. If you click a link and hear nothing, the navigation is broken for screen reader users.
Check Focus After Route Changes
After every route change, inspect where focus is. Open browser DevTools, navigate to a new route, and check document.activeElement. If focus is on the <body> or on an element that no longer exists in the DOM, focus management is broken.
// Debug helper: log focus changes in the console
document.addEventListener('focusin', (event) => {
console.log('Focus moved to:', event.target);
console.log(' Tag:', event.target.tagName);
console.log(' Text:', event.target.textContent
?.substring(0, 50));
});
Verify Live Region Announcements
Live regions can be tricky to debug because they rely on timing. Use browser extensions like the Accessibility Insights extension or inspect the accessibility tree in DevTools to verify that live regions are correctly configured and their content updates are being detected.
Test Back and Forward Buttons
Navigate forward through several routes, then use the browser's back and forward buttons. Verify that each navigation is announced, focus is managed correctly, and the document title updates. Many SPAs handle forward navigation correctly but break on back/forward because the popstate event handler doesn't include accessibility logic.
<h1>, and (6) all interactive content is keyboard accessible.
// 08 · framework quick reference
Framework Quick Reference
Each major JavaScript framework handles SPA accessibility differently. This table summarizes the recommended approach for route announcements, focus management, and helpful libraries for each framework.
| Framework | Route Announcements | Focus Management | Recommended Libraries |
|---|---|---|---|
| React | Use a live region component that listens to route changes via useLocation(). React Router v6+ does not include built-in announcements. |
Use a useEffect hook to focus the <h1> after route changes. Use ref to reference the heading element. |
@reach/router (built-in focus management), react-aria (Adobe), react-helmet (title updates) |
| Vue | Use vue-announcer or a custom live region updated in the router.afterEach navigation guard. |
Use router.afterEach combined with nextTick() to focus the new page heading after the DOM updates. |
vue-announcer, vue-a11y-utils, @vueuse/core (useFocus) |
| Angular | Use LiveAnnouncer from @angular/cdk/a11y in a route change subscriber. |
Subscribe to router events and use ViewChild to get a reference to the heading, then call nativeElement.focus(). |
@angular/cdk/a11y (LiveAnnouncer, FocusTrap, FocusMonitor), Title service for document title |
| Svelte | Use an afterNavigate lifecycle function in SvelteKit to update a live region or announce via a store. |
Use afterNavigate to focus the heading after each navigation. Use tick() to wait for DOM updates. |
SvelteKit has built-in afterNavigate. Use svelte-announcer for live region management. |