Focus Management During Route Transitions
Part of Focus & State Motion Accessibility in Accessible Motion Architecture.
The problem
A single-page application animates between routes with a view transition. Visually it is excellent. For a keyboard user, clicking a navigation link leaves focus on the link: pressing Tab moves to the next navigation item, not into the new page, and the browser’s “skip to content” link now points at content that is three routes away in tab order. A screen reader user hears nothing at all — the page changed, but no announcement was made, because no document load occurred.
Animation is not the cause, but it is where the problem is usually discovered, because teams build route transitions and route accessibility at the same time.
Root cause analysis: no navigation, no reset
A full page load resets focus to the document and announces the new page. A client-side route change does neither: the DOM is replaced, the URL is updated, and focus stays exactly where it was — often on an element that no longer exists, in which case focus falls back to the body and the user’s position is lost entirely.
Three things have to be restored deliberately.
Focus position. The convention with the best support is to move focus to the new page’s main heading, made programmatically focusable with tabindex="-1", or to the main landmark. Both put the user at the start of the new content, so Tab continues into it.
Announcement. Updating document.title is what assistive technology reports on a real navigation; for a client-side change, pairing it with a polite live region message such as “Pricing, page loaded” is the pattern that works across screen readers.
Timing relative to the animation. Focus should move as soon as the new content exists, not when the animation finishes. A view transition pauses rendering during its update callback and then animates snapshots; the real DOM is already updated and focusable while the snapshots animate. Waiting for finished delays keyboard users for the length of the animation, and if the transition is skipped the promise rejects and focus may never move at all — which is why updateCallbackDone is the right signal, as described in integrating view transitions with client-side routers.
Back navigation is different. Returning to a list from a detail page should restore focus to the item the user came from, which requires remembering it before navigating.
Step-by-step resolution
Production code pattern
<div id="route">
<h1 id="route-heading" tabindex="-1">Pricing</h1>
…
</div>
<p id="route-status" role="status" aria-live="polite" class="visually-hidden"></p>
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const focusHistory = new Map(); // navigation key -> element selector
function afterRender(url, { restore } = {}) {
document.title = `${titleFor(url)} — Example`;
const target = restore && document.querySelector(restore);
const heading = document.getElementById('route-heading');
(target ?? heading)?.focus({ preventScroll: true });
document.getElementById('route-status').textContent = `${titleFor(url)}, page loaded`;
}
async function navigate(url, { trigger, restore } = {}) {
if (trigger) focusHistory.set(location.href, selectorFor(trigger));
const update = () => renderRoute(url);
if (!document.startViewTransition || reduce.matches) {
update();
afterRender(url, { restore });
return;
}
const transition = document.startViewTransition(update);
await transition.updateCallbackDone; // the new DOM exists; the animation is still running
afterRender(url, { restore }); // focus moves now, not after the animation
}
addEventListener('popstate', () => {
navigate(location.href, { restore: focusHistory.get(location.href) });
});
#route-heading:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: 4px;
}
/* No focus ring for programmatic focus that the user did not request with the keyboard;
:focus-visible handles that distinction automatically. */
Rendering Impact: none. Focus and announcements are not animations; the important interaction is timing, since moving focus mid-transition does not disturb the snapshot animation — the snapshots are images and are unaffected by focus.
Using :focus-visible on the heading means mouse users do not see a focus ring appear on every navigation, while keyboard users do. Using preventScroll: true avoids fighting the route’s own scroll restoration.
Verification checklist
Constraints and trade-offs
tabindex="-1"headings are focusable programmatically only, which is intended.- Restoring focus on back navigation requires a stable way to identify elements.
- Some screen readers announce title changes and live regions differently; testing across two is worth the time.
- Announcing on every navigation can be verbose for rapid browsing; keep messages short.
- Moving focus can scroll the page;
preventScrollplus deliberate scroll restoration avoids conflicts.
Frequently asked questions
Where should focus go after a client-side route change?
To the new page’s main heading, made focusable with tabindex=“-1”, or to the main landmark, so keyboard order continues into the new content.
Should focus wait until the transition animation finishes?
No. Move focus as soon as the new DOM exists, using updateCallbackDone rather than finished, so keyboard users are not delayed by the animation.
How do screen reader users know the page changed?
Update document.title and announce a short message in a polite live region; client-side navigation does not announce anything by itself.
What about the back button?
Remember the element that triggered the navigation and restore focus to it when returning, so users come back to where they left.
Related
- Focus & State Motion Accessibility — the parent topic
- Integrating View Transitions with Client-Side Routers — where this hooks into the router
- Animating ARIA Live Region Updates — making announcements reliable