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 - The autofocus attribute 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 tabindex immediately - Wait for the blur event before removing tabindex="-1" from the heading.

Resources

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.

- Vinay Ranjan