Small Code, Big Barrier: How to Fix Focus Management in Single-Page Apps for Screen-Reader Users
Small Code, Big Barrier
How to Fix Focus Management in Single-Page Apps for Screen-Reader Users
The bug is invisible to most mouse users, ships in countless React, Vue, and Svelte applications, and I found it on my own website.
Imagine you're near the bottom of a web page. You activate a link in the footer to navigate somewhere else.
The new page appears.
But where are you?
On a traditional multi-page website, where navigation loads a new HTML document, the browser handles much of this transition for you. The new document loads from the beginning, the viewport returns to the top, and assistive technologies can begin navigating the newly loaded content.
In a single-page application (SPA), however, something very different happens.
The browser never actually loads a new document.
Frameworks such as React, Vue, and Svelte typically use client-side routing. When you follow an internal link, the application replaces part of the existing DOM with the content for the new route. The URL changes and the visible content changes, but from the browser's perspective, you are still inside the same document.
That creates an accessibility problem that is extremely easy to miss:
The page changed, but the user's focus did not.
If you activated a link near the bottom of the previous page, keyboard focus may remain there even after the new page appears.
For a sighted mouse user, the problem may barely be noticeable. They can visually scan the screen and immediately determine where the new content begins.
For keyboard and screen-reader users, the experience can be very different.
A keyboard user may find their focus stranded somewhere that no longer makes sense in the context of the new page.
A screen-reader user may receive little or no indication that meaningful new content has appeared. Their reading position does not automatically jump to the beginning of the new page.
This becomes particularly frustrating on phones and tablets. A screen-reader user cannot simply glance toward the top of the screen to understand what changed. They may have to swipe backwards repeatedly through the newly rendered content just to locate the page heading.
In effect, they can end up navigating the page in reverse simply to discover where the new page begins.
That is a significant barrier caused by a surprisingly small piece of missing code.
First, Let's Identify the Real Cause
This problem is sometimes described as a difference between server-side rendering and client-side rendering.
That is not quite accurate.
My own website uses server-side rendering. The initial page arrives with its content, headings, landmarks, and other HTML already rendered.
But subsequent navigation is client-side.
That transition is where the accessibility problem occurs.
The important distinction, therefore, is not simply:
SSR vs. CSR
It is whether navigation causes the browser to load an entirely new document or whether the application performs client-side routing inside the existing document.
If your framework performs client-side navigation, you need to consider what should happen to keyboard focus after each route change.
The Fix: Manage Focus After Navigation
The fundamental solution is straightforward.
When a client-side route changes:
-
Reset the viewport to the top.
-
Identify an appropriate element at the beginning of the new page.
-
Move keyboard focus to that element.
-
Allow the screen reader to announce it so the user understands where navigation has taken them.
The main <h1> is often a good focus target because it communicates the purpose of the newly loaded page.
Here is the basic idea in React:
// Runs on every client-side navigation.
useEffect(() => {
window.scrollTo(0, 0);
// Adjust this selector to match your application's markup.
const heading = document.querySelector("#main h1");
if (heading) {
heading.setAttribute("tabindex", "-1");
heading.focus();
}
}, [pathname]);
In this example, the application assumes that the primary page content lives inside:
<main id="main">
and that the page contains an <h1>.
If your application uses different markup, change the selector accordingly. For example:
document.querySelector("main h1");
You could also create a dedicated focus target at the beginning of each page.
The exact selector is not the important part.
The principle is to deliberately establish a logical starting point for the newly rendered page.
Why tabindex="-1"?
An <h1> is not normally focusable.
That is generally correct behaviour. We do not want every heading appearing in the normal Tab sequence.
But sometimes we need to move focus to a heading programmatically.
That is where:
tabindex="-1"
becomes useful.
A value of -1 allows an element to receive focus programmatically without placing that element into the normal sequential keyboard Tab order.
We can therefore do this:
heading.setAttribute("tabindex", "-1");
heading.focus();
The heading receives focus, and a screen reader can announce it, giving the user immediate context for the page they have just entered.
Afterward, pressing Tab continues through the page's normal interactive controls rather than turning every heading into a Tab stop.
The Simple Version Isn't Quite Enough
The basic solution demonstrates the concept.
Shipping it reliably requires handling several additional details.
These details matter because accessibility bugs often hide in the difference between a controlled demonstration and the way people actually interact with a website.
Don't Steal Focus on the Initial Page Load
The focus-management routine should normally respond to client-side navigation, not simply the first time the application component mounts.
When someone initially arrives on the website, the browser and assistive technology already have their own document-loading behaviour.
There is usually no reason for your application to immediately grab focus.
Track whether an actual route transition has occurred and run your focus routine in response to navigation rather than blindly focusing something during initialisation.
Don't Break In-Page Anchors
Consider a URL such as:
/about#leadership
The #leadership fragment has a purpose: it identifies a particular location within the page.
Your global route-focus logic should not override legitimate fragment navigation by immediately forcing the user back to the <h1>.
If the navigation includes an in-page anchor, allow the browser or your application's anchor-handling mechanism to reach the intended target.
Wait Until the New Heading Actually Exists
This was one of the most important problems I encountered.
Not every page renders its final content immediately after the URL changes.
A page might need to:
-
fetch data;
-
resolve asynchronous state;
-
render a loading interface;
-
hydrate client-side content; or
-
wait for another component to mount.
The URL can therefore change before the new page's real <h1> exists.
If your focus-management code runs immediately, it might find the old page's heading while that page is still being removed.
Or it might find nothing at all.
Then the new page eventually renders and the user's focus ends up somewhere useless, sometimes effectively falling back to the document body.
The solution is not simply:
Focus the first heading you can find.
Instead, remember the outgoing page's heading and wait until a different heading belonging to the new page has actually mounted.
A timeout can provide a fallback so the application does not wait indefinitely.
Conceptually, the sequence becomes:
Route changes → reset scroll → wait for new content → detect the new heading → move focus.
That distinction turns a convenient demo into a much more robust implementation.
Don't Let the Clicked Link Trap Focus
This is another subtle implementation mistake — and my first version had it.
Suppose a user clicks a navigation link.
A real mouse click normally gives that link focus.
You might have written defensive focus-management logic that effectively says:
If focus is already on an interactive control, don't move it.
That sounds reasonable because we generally do not want applications unexpectedly stealing focus from users.
But during navigation, that rule can create exactly the barrier we're trying to solve.
The user's focus is still on the link that initiated the navigation.
If that link lives in persistent navigation — perhaps a header or footer that does not unmount between routes — focus can remain there even after the new page has loaded.
Your accessibility code sees that something already has focus and decides not to intervene.
The user is left behind.
I discovered an additional testing trap here: a scripted, programmatic .click() does not necessarily reproduce the focus behaviour of an actual user click.
So an automated test can appear to pass while the real interaction still fails.
The better rule is:
After navigation, move focus to the new page heading even if the link that initiated navigation currently has focus.
There is an important exception.
If the user has already deliberately moved focus into the newly rendered page content, don't fight them.
Good focus management should establish context after navigation, not compete with the user.
The Complete Mental Model
The implementation can be reduced to four responsibilities:
Reset scroll. Wait for the new content. Focus the new heading. Don't fight the user.
That is the behaviour we are trying to recreate.
Not because a single-page application needs to imitate every technical detail of a traditional document load, but because users need an equivalent understanding of what just happened:
I activated a link. I arrived somewhere new. This is the beginning of that new content.
That context is essential when the interface cannot be understood visually.
Where Automated Accessibility Testing Stops
There is another lesson here for accessibility testing.
A conventional accessibility scanner can inspect a page and identify many important problems:
-
missing alternative text;
-
invalid ARIA usage;
-
missing form labels;
-
insufficient accessible names;
-
structural problems;
-
certain colour-contrast failures; and
-
many other detectable WCAG-related issues.
But focus failing to move after navigation is different.
It is not simply a property of a single DOM snapshot.
It is a behaviour that occurs during the transition between states.
A scanner can inspect Page A.
It can inspect Page B.
Both pages might look perfectly reasonable in isolation.
The barrier exists in this sequence:
Page A → user activates link → application changes route → Page B appears → focus remains in the wrong place.
If your accessibility testing only photographs the DOM after the transition, it can miss what happened during that transition.
That distinction matters.
Accessibility is not only about what exists on a page.
It is also about what happens when someone uses it.
Why I Built Webscan to Go Beyond Page Snapshots
I'll acknowledge something important here as someone building an accessibility-testing platform: a scanner that only inspects a static snapshot cannot reliably identify every behavioural accessibility problem.
That is one reason I didn't want Webscan to stop at conventional page scanning.
Webscan runs accessibility checks in a real browser, but the goal extends beyond checking isolated pages.
It Tests Journeys, Not Just Pages
Accessibility barriers frequently appear during interactions.
Open a menu.
Follow a link.
Submit a form.
Trigger validation.
Open a modal.
Move between application routes.
Webscan can examine journeys so accessibility testing includes what happens between interface states, not only what exists after the final state has rendered.
It Captures Real Sessions
Real users do things that developers and automated test scripts do not always anticipate.
Session recording allows an actual journey through an application to become part of the accessibility evidence.
When something breaks during that journey, behavioural barriers have a better chance of becoming findings instead of disappearing between two successful page scans.
It Explains the Problem
Finding a violation is only the beginning.
Developers need to know:
What is wrong? Who does it affect? Why does it matter? What should I change?
Accessibility reports should help teams fix barriers rather than simply present cryptic rule identifiers.
It Helps Fix and Verify
The workflow should not end when a problem is discovered.
Webscan is designed around a broader cycle:
Find it → Fix it → Verify it.
That means identifying the accessibility problem, generating an appropriate code change, opening the fix as a pull request against the repository, and re-scanning the application to verify that the barrier has actually been addressed.
The objective is not merely to improve an accessibility score.
It is to improve the experience.
Learn more about Webscan at:
webscan.inclusiveappcraft.com
Try This 30-Second Accessibility Test
You can test your own single-page application right now.
You do not need an accessibility scanner.
Navigate somewhere inside your application using one of its internal links.
Then, without using your mouse again, press Tab.
Ask yourself:
Where did focus go?
If focus remains stranded around the control that initiated navigation, or somewhere unrelated to the beginning of the newly loaded content, investigate your application's route focus management.
For an even more meaningful test, turn on a screen reader and perform the same navigation.
Listen to what happens.
Does the application clearly communicate that you have arrived on a new page?
Does the new page's heading get announced?
Can you immediately begin navigating the new content logically?
Or are you left trying to work out where the application placed you?
Small implementation details can create disproportionately large accessibility barriers.
This is one of them.
Accessibility is a practice, not a score.
Appendix: Getting the Route-Change Signal
The underlying focus-management routine is largely framework-independent.
What changes is how each framework tells you that navigation has occurred.
Once you receive that signal, call the same manageFocus() routine responsible for resetting scroll, waiting for the new content, and moving focus appropriately.
React Router v6+
import { useLocation } from "react-router-dom";
const { pathname } = useLocation();
useEffect(() => {
manageFocus();
}, [pathname]);
TanStack Router
import { useRouterState } from "@tanstack/react-router";
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
useEffect(() => {
manageFocus();
}, [pathname]);
Next.js App Router
"use client";
import { usePathname } from "next/navigation";
const pathname = usePathname();
useEffect(() => {
manageFocus();
}, [pathname]);
Vue Router
import { watch } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
watch(
() => route.path,
() => manageFocus()
);
SvelteKit
import { afterNavigate } from "$app/navigation";
afterNavigate(() => {
manageFocus();
});
Regardless of framework, the accessibility principle remains the same:
When your application creates the experience of moving to a new page without actually loading a new document, your application also takes responsibility for restoring the navigation context that a full document load would otherwise provide.
That is a small amount of code.
But for the person relying on keyboard navigation or a screen reader, it can be the difference between arriving at a page and being stranded inside it.
Was this article helpful?
Comments
No comments yet. Be the first to share your thoughts.
Leave a comment
Related reading

