Single-Page Apps & Route Changes
In a traditional website, every click on a link loads a new page. The browser resets focus to the top, the screen reader reads the new title, and the user knows they have arrived. In a single-page app (SPA), built with frameworks such as React, Vue or Angular, JavaScript swaps the content without loading a new page. That is fast, but none of the browser's built-in signals happen. A screen reader user clicks "Orders" and hears nothing at all.
In this lesson you will learn what goes wrong during SPA route changes and how to fix it with four small habits: real links, a new title, focus on the new heading, and announcements for loading.
What breaks in a single-page app
When the app changes the view with JavaScript:
- No announcement. Screen readers announce a new page on a real page load, not when content is swapped.
- Lost focus. Focus stays on the link that was clicked; if that link is removed in the new view, focus falls back to the
<body>, and the next Tab starts from the top. - Stale title. If the code does not update
document.title, every view has the same title, so 2.4.2 Page Titled fails and the browser history is useless. - Broken Back button if the app does not update the URL.
- Scroll position may stay where it was, so the new view appears half-scrolled.
The four habits
- Use real links (
<a href="/orders">) for navigation, and update the URL with the History API, so that Back, bookmarks and "open in new tab" work. - Update
document.titlefor every view: "Orders - ShopName". - Move focus to the main heading of the new view (give it
tabindex="-1"), or to the<main>element. The screen reader reads the heading, and the next Tab continues from there. - Announce loading with a polite status message if the new view takes time, and mark the region with
aria-busy="true"while it loads.
Run this small app. It uses the URL hash for routing so that it works inside the Try-it editor; real apps usually use the History API, shown after it. Use the checkbox to compare the app with and without focus management.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Home - Rohtak Library App</title>
<style>
nav a { margin-right: 12px; }
nav a[aria-current="page"] { font-weight: bold; }
h1:focus { outline: none; }
h1:focus-visible { outline: 3px solid #0b5cad; }
</style>
</head>
<body>
<p><label><input type="checkbox" id="manage" checked> Manage focus and title</label></p>
<nav aria-label="Main">
<a href="#home">Home</a>
<a href="#books">Books</a>
<a href="#account">My account</a>
</nav>
<main id="view"></main>
<script>
const views = {
home: { title: "Home", body: "<p>Welcome to the Rohtak Library app.</p>" },
books: { title: "Books", body: "<ul><li>Godaan</li><li>Wings of Fire</li><li>Malgudi Days</li></ul>" },
account: { title: "My account", body: "<p>You have 2 books issued. Return by 12 November.</p>" },
};
function render() {
const key = location.hash.slice(1) || "home";
const view = views[key] || views.home;
const main = document.querySelector("#view");
main.innerHTML = `<h1 tabindex="-1">${view.title}</h1>${view.body}`;
for (const a of document.querySelectorAll("nav a")) {
if (a.hash === "#" + key) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}
if (document.querySelector("#manage").checked) {
document.title = view.title + " - Rohtak Library App";
main.querySelector("h1").focus();
window.scrollTo(0, 0);
}
}
window.addEventListener("hashchange", render);
render();
</script>
</body>
</html>With the checkbox ticked, activating "Books" moves focus to the "Books" heading, so a screen reader announces "Books, heading level 1", and the tab title changes. Untick it and try again: focus stays on the link, the title never changes, and a screen reader user gets no sign that anything happened.
The h1:focus { outline: none; } rule is one of the few safe uses: the heading is not interactive, and :focus-visible still shows a ring for keyboard users in browsers that decide one is needed. The view HTML here comes from the app's own fixed strings; never insert user-provided text with innerHTML.
The same idea with the History API
In a real app, links point at real paths and a router updates the URL with history.pushState(). The accessibility steps are the same:
async function navigate(path) {
const main = document.querySelector("main");
main.setAttribute("aria-busy", "true");
const view = await loadView(path); // your router or framework does this
main.replaceChildren(view.content);
main.removeAttribute("aria-busy");
history.pushState({}, "", path);
document.title = `${view.title} - Rohtak Library App`;
main.querySelector("h1").focus(); // the h1 has tabindex="-1"
}
document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-internal]");
if (!link) return;
event.preventDefault();
navigate(link.pathname);
});
window.addEventListener("popstate", () => navigate(location.pathname));The popstate listener makes the Back and Forward buttons render the right view. Many frameworks offer hooks for this. Some, such as Next.js, include a built-in route announcer; others leave it to you. Whatever you use, test it with a keyboard and a screen reader.
Announcing slow loads
If a view needs data from a server, users must know the app is working. A polite status message and aria-busy cover this.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Loading Results</title>
</head>
<body>
<h1>Exam results</h1>
<button type="button" id="load">Show my result</button>
<p id="status" role="status"></p>
<section id="result" aria-labelledby="result-title" hidden>
<h2 id="result-title" tabindex="-1">Your result</h2>
<p id="marks"></p>
</section>
<script>
document.querySelector("#load").addEventListener("click", () => {
const status = document.querySelector("#status");
const section = document.querySelector("#result");
status.textContent = "Loading your result...";
section.setAttribute("aria-busy", "true");
setTimeout(() => {
document.querySelector("#marks").textContent = "Ananya Sharma: 462 out of 500, first division.";
section.hidden = false;
section.removeAttribute("aria-busy");
status.textContent = "";
document.querySelector("#result-title").focus();
}, 1500);
});
</script>
</body>
</html>The user hears "Loading your result..." straight away. When the data arrives, focus moves to the "Your result" heading, so the result is read next. The status text is cleared because the focus move already tells the user what happened. In a real app, also handle errors: if loading fails, say so in the status region and keep focus on the button.
Common mistakes
- Using
<div onclick>or buttons for navigation. Use links withhref, so the URL and Back button work. - Never updating
document.title. - Moving focus to the top of the document instead of the new content, forcing users to Tab through the whole menu again.
- Moving focus on every small update. Move focus only for real view changes; use live regions for small status messages.
- Announcing the route and also moving focus to a heading, which makes the screen reader speak twice. Pick one main signal.
Key points
- SPAs swap content without the browser's built-in page-load signals.
- Use real links and update the URL with the History API so Back and bookmarks work.
- Update
document.titleand move focus to the new view's heading (withtabindex="-1") on each route change. - Mark loading regions with
aria-busyand announce slow loads with a polite status message. - Test route changes with a keyboard and a screen reader, whatever framework you use.
Exercise
Add a fourth view, "Events", to the Try-it library app. Its heading is "Events" and its body is a list of two events: a book reading on 15 November and a quiz on 22 November. Check that the title, focus and aria-current all update when you open it.
Show solution
<a href="#events">Events</a>
<script>
// add to the views object
views.events = {
title: "Events",
body: "<ul><li>Book reading, 15 November</li><li>Library quiz, 22 November</li></ul>",
};
</script>Add the link inside the <nav>, and add the events entry to the views object before render() first runs. Because render() handles every view the same way, the new view gets its title, focus on its heading and aria-current="page" on its link with no extra code.
Quick Quiz
1. Why do screen reader users often hear nothing after an SPA route change?
Without a real page load, the app must update title and focus itself.
2. Where should focus usually go after a route change?
Focusing the heading announces the new view and starts reading from the right place.
3. What does aria-busy="true" signal?
aria-busy tells assistive technology to wait until the update is finished.
4. Which listener makes the browser Back button render the right view in an app using pushState?
The popstate event fires when the user goes Back or Forward through history entries.
Learn Web Accessibility with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
