Focus management in SPAs
Single-page applications (SPAs) update content without full page reloads. This creates a significant accessibility problem - screen readers don’t announce route changes, and keyboard focus stays on the element that triggered the navigation.
The problem with client-side routing
When a traditional website navigates to a new page, the browser resets focus to the top of the document and screen readers announce the new page title. SPAs skip this entirely:
<!-- User clicks a link -->
<a href="/about">About us</a>
<!-- SPA replaces content in-place - focus stays on the link -->
<div id="app">
<!-- New content appears but screen reader says nothing -->
</div>Users who rely on screen readers may not know navigation occurred. Keyboard users remain focused on a link that may no longer be visible.
Moving focus after navigation
After a route change, move focus to the new content’s heading or a container with the page title:
function onRouteChange() {
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
}Setting tabindex="-1" makes the heading programmatically focusable without adding it to the tab order.
Announcing route changes with live regions
An alternative approach uses an aria-live region to announce the new page:
<div aria-live="polite" aria-atomic="true" class="sr-only" id="route-announcer"></div>function announceRouteChange(pageTitle) {
const announcer = document.getElementById('route-announcer');
announcer.textContent = '';
requestAnimationFrame(() => {
announcer.textContent = `Navigated to ${pageTitle}`;
});
}The empty-then-fill pattern ensures the screen reader detects the change every time.
Framework-specific patterns
React with React Router
function RouteAnnouncer() {
const location = useLocation();
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: false });
setAnnouncement(`Navigated to ${heading.textContent}`);
}
}, [location.pathname]);
return (
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
);
}Angular
@Injectable({ providedIn: 'root' })
export class FocusService {
constructor(private router: Router) {
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
setTimeout(() => {
const heading = document.querySelector('h1');
if (heading instanceof HTMLElement) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
});
}
}Loading states
When content is loading, inform screen reader users:
<div aria-busy="true" aria-live="polite">
<p>Loading content...</p>
</div>Once content loads, remove aria-busy and move focus:
function onContentLoaded(container) {
container.removeAttribute('aria-busy');
const heading = container.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
}Common mistakes
- Focusing a non-descriptive element - Moving focus to a
<div>instead of a heading gives no context about where the user landed. - Using
autofocus- Theautofocusattribute only works on initial page load, not on dynamically inserted content. - Scrolling without focusing -
scrollIntoView()moves the viewport but doesn’t move screen reader focus. - Removing
tabindeximmediately - Wait for theblurevent before removingtabindex="-1"from the heading.
Resources
- Managing Focus in Single Page Applications - Deque
- What we learned from user testing of accessible client-side routing - GDS
- WCAG 2.4.3 Focus Order
This pairs well with accessible navigation and skip links and using keyboard for content accessibility if you haven’t read those yet.
If you found this helpful, share it with someone who's building for the web.