EthicsComputerCourses
Join training +91 90532 10052

Single-Page Apps & Route Changes

Web Accessibility · Lesson 24 of 28 Advanced 6 min read Updated

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

  1. 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.
  2. Update document.title for every view: "Orders - ShopName".
  3. 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.
  4. 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.

HTMLTry it yourself
<!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:

JavaScript
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.

HTMLTry it yourself
<!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 with href, 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.title and move focus to the new view's heading (with tabindex="-1") on each route change.
  • Mark loading regions with aria-busy and 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
HTML
<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?

2. Where should focus usually go after a route change?

3. What does aria-busy="true" signal?

4. Which listener makes the browser Back button render the right view in an app using pushState?

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