Focus Management in Single-Page Apps, Accessibly
Single-page apps feel fast and modern: a click on a link swaps the content without fully reloading the page. But this very convenience often turns into a barrier for keyboard and screen reader users. With a classic page change, the browser resets focus and the screen reader reads out the new page title. In a SPA this does not happen on its own: focus stays stuck on the clicked link, the page title remains unchanged, and the view change is not announced. How widespread operability problems are overall is shown by the WebAIM Million Report: 95.9% (WebAIM Million, 2024) of all home pages analyzed had detectable WCAG failures, averaging 56.8 (WebAIM Million, 2024) per page. This guide shows how to make route changes in JavaScript frameworks accessible - with correct focus management, a maintained page title and an announcement via a live region - as a solid basis for your accessible web development.
Key takeaways
- On a route change the browser handles no focus management - the app has to actively move focus to the new view.
- Usually focus moves to the main heading (H1) of the new route, which is made focusable for this purpose with tabindex=-1.
- The page title (document.title) must change on every view change so screen readers and bookmarks reflect the correct view (WCAG 2.4.2).
- A politely updated live region (aria-live=polite) can additionally announce the change when focus alone is not enough (WCAG 4.1.3).
- React, Vue, Angular and Svelte ship no built-in focus management - it remains the application's job to add it deliberately.
Why Route Changes in SPAs Become a Barrier
On a classic website every link leads to a new document. The browser loads the page, sets focus to the start of the document and the screen reader reads out the new page title - all without any developer action. In a single-page app, clicking an internal link technically does something completely different: the router intercepts the navigation, swaps part of the DOM and updates the URL via the History API. There is no real page change, so the browser does not reset focus either. Focus remains on the clicked link, which after the view change often no longer exists (W3C/WAI).
For sighted mouse users this is invisible. For keyboard and screen reader users it is a real problem: the screen reader stays silent because it does not actively monitor changes to the page title, and the next Tab step leads to an unpredictable spot. This situation affects many people directly: at the end of 2023, around 7.9M (Federal Statistical Office, 2024) people with severe disabilities lived in Germany, of whom around 4% (Federal Statistical Office, 2024) had blindness or a vision impairment. Because SPA frameworks dominate the market today - 40.6% (Stack Overflow Developer Survey, 2024) of developers use React, while others use Vue or Angular - the topic affects a very large share of modern web applications.
The browser does not step in automatically
The Three WCAG Building Blocks of an Accessible Route Change
An accessible route change emerges from the interplay of three measures. None of them fully replaces the others - together they ensure that all user groups notice that the view has changed. The following overview summarizes which WCAG success criteria work together here (W3C/WAI). We check these points systematically as part of our services.
Set focus
After the view change, focus moves to the main heading or main content of the new route, which is made focusable for this with tabindex=-1 (WCAG 2.4.3).
Maintain the title
document.title changes on every route change and describes the current view, so bookmarks and screen readers reflect the right page (WCAG 2.4.2).
Announce the change
A politely updated live region (aria-live=polite) communicates the change when the focus shift alone is not clear enough (WCAG 4.1.3).
In practice, focus management forms the foundation, the page title provides orientation, and the live region is the complementary safeguard. Which mix makes sense depends on the framework and the complexity of the view. These building blocks build directly on the basics of keyboard navigation in web development and complement the clean use of ARIA roles, states and live regions.
The lifecycle of an accessible route change
- 1
Link is activated
A click or keyboard triggers an internal link; the router intercepts the navigation and prevents the full reload.
- 2
Router renders the new view
The new content is inserted into the DOM, the URL is updated via the History API and the page title is set accordingly.
- 3
Focus is set
Focus moves programmatically to the new main heading or main content, which was made focusable with tabindex=-1.
- 4
Change is announced
A live region politely reports the change in case the focus shift alone does not communicate it sufficiently.
Setting Focus: On the Heading of the New Route
The most reliable approach is to set focus to the main heading (H1) of the new view after the route change. Headings are not focusable by default, so they receive tabindex=-1, which lets them be focused via JavaScript without entering the natural Tab order. When focused, the screen reader reads out the heading - the user immediately learns where they have landed, and the next Tab step starts sensibly in the new content. Alternatively, focus can be set on a container with the role main or a dedicated anchor at the top of the page (W3C/WAI).
The timing matters: focus may only be set once the new content is in the DOM and visible. In frameworks this means setting focus after the render cycle - for example in an effect hook or lifecycle callback that runs after the new content has been inserted. A visible focus indicator is mandatory so sighted keyboard users recognize the new position. The details are deepened in our article on keyboard navigation in web development.
// After every route change, move focus to the
// new main heading.
function onRouteChanged() {
const heading = document.querySelector('main h1');
if (heading) {
// Make the heading programmatically focusable
// without adding it to the Tab order.
heading.setAttribute('tabindex', '-1');
heading.focus();
}
}import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export function RouteView({ title, children }) {
const headingRef = useRef(null);
const location = useLocation();
useEffect(() => {
document.title = title; // maintain the page title
headingRef.current?.focus(); // focus the new view
}, [location.pathname, title]);
return (
<main>
<h1 tabIndex={-1} ref={headingRef}>{title}</h1>
{children}
</main>
);
}Common mistake: focus set too early or not at all
Maintaining the Title and Announcing the Change
Every route needs its own meaningful page title. WCAG 2.4.2 Page Titled requires that every view has a descriptive title that names its purpose (W3C/WAI). In a SPA, document.title has to be set actively on every route change because the browser no longer takes it from a new document. A maintained title helps not only screen reader users but also with setting bookmarks, switching between browser tabs and in history. Screen readers, however, only read the title out on an actual page load and do not actively monitor title changes - so the title does not replace focus management but complements it.
When the focus shift alone is not enough - for example for partial updates within the same route - a live region helps. An empty element with aria-live=polite that is filled with a short text such as Products loaded on the change announces the new view politely without interrupting the user. WCAG 4.1.3 Status Messages describes exactly this mechanism: status changes should be made perceivable without a focus shift (W3C/WAI). How live regions are built correctly without producing double announcements is deepened in our article on ARIA roles, states and live regions.
<!-- Global, visually hidden live region.
Once in the app layout, outside the router outlet. -->
<div id="route-announcer"
aria-live="polite"
aria-atomic="true"
class="visually-hidden"></div>// Announce the new view on a route change.
function announceRoute(title) {
const region = document.getElementById('route-announcer');
if (!region) return;
// Clear briefly so identical texts are
// read out again.
region.textContent = '';
window.requestAnimationFrame(() => {
region.textContent = title + ' loaded';
});
}| Aspect | Common mistake | Implemented accessibly |
|---|---|---|
| Focus after change | Focus stays on the old link | Focus moves to the new heading |
| Page title | Title stays unchanged | document.title reflects the route |
| Announcement | No hint of the change | Live region reports the new view |
| Tab order | Next Tab jumps unpredictably | Next Tab starts in the new content |
| Focus indicator | No visible focus | Clearly visible focus ring |
| Timing | Focus set before the render | Focus set after the render cycle |
Framework Differences: React, Vue, Angular and Svelte
None of the common JavaScript frameworks ships built-in focus management for route changes - it generally remains the application's job. The frameworks differ, however, in where the right moment to set focus lies. In React a useEffect hook reacting to the path change is suitable; in Vue the router hook afterEach combined with nextTick; in Angular the router's NavigationEnd event; and in Svelte an after-navigate callback. What they all share is that focus may only be set once the new view is in the DOM.
It is also important that a pattern, once built, applies consistently across the entire application. In supporting 50+ (project experience) projects we often see that focus management was only implemented for individual routes - the rest of the application then stays inaccessible. A central component that handles title, focus and announcement for every route significantly reduces these gaps. The same care applies to dynamically loaded content, as our article on accessible error and status messages shows.
React
A useEffect hook reacts to the path change, sets document.title and focuses the new heading after the render.
Vue
The router hook afterEach sets the title; nextTick ensures focus is only set after the DOM update.
Angular
The router's NavigationEnd event is suitable for setting the title and moving focus to the new main content.
Svelte
An after-navigate callback offers the right moment to update title, focus and live region announcement.
A central route view component as a robust foundation
Special Cases: Partial Updates, Modals and Infinite Scroll
Not every change in a SPA is a full route change. A filter that only swaps the results list should not force focus but report the result via a live region - otherwise the user loses their position. If a modal dialog opens on a route change, its own rules apply: focus trap, inert background and return focus, as described in our article on accessible modals and overlays. With infinite scroll, in turn, focus should not jump when content is loaded, and new content should be inserted so the Tab order is preserved.
The behavior on back navigation also deserves attention. When a user opens an earlier route via the browser's back button, the app should ideally restore the previous scroll and focus position. This is demanding but a clear gain especially for keyboard users. Movements on the view change should respect prefers-reduced-motion so animations do not overwhelm anyone.
- On a route change, focus set to the new main heading (H1, tabindex=-1)
- Focus set only after the render cycle, onto an existing visible element
- document.title updated on every route change (WCAG 2.4.2)
- Global live region (aria-live=polite) announces the change (WCAG 4.1.3)
- Visible focus indicator present on the focused target
- Partial updates report results without forcing focus
- Back navigation restores scroll and focus position where possible
- Verified with keyboard and screen reader across all routes
A single-page app is only navigable accessibly once every route change carries focus along. Whoever only swaps the content but forgets focus leaves keyboard and screen reader users in the dark.
Route changes touch several WCAG success criteria at once: 2.4.2 Page Titled, 2.4.3 Focus Order and 4.1.3 Status Messages (W3C/WAI). A cleanly implemented focus management is therefore often a lever that addresses several findings in a WCAG audit at the same time. If you anchor the pattern in a central component from the start, you avoid an entire class of recurring errors - and create a reliable basis for your accessible web development.