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
If you found this helpful, share it with someone who's building for the web.