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.

When focus should move during an animated route changeFocus moves when the DOM is ready, not when the animation ends.When focus should move during an animated route changeRoute renderrendersettled400 msView transitionanimate snapshotsdone400 msFocus + announcementwait for DOMuser can act400 msrendersettledcaptureanimate snapshotsdonewait for DOM
Focus moves when the DOM is ready, not when the animation ends.

Step-by-step resolution

Wiring focus into the routerSame logic with or without animation.Wiring focus into the router1Give each route's main heading tabindex="-1".Programmatically focusable2Before navigating, record the element that triggered it.Restorable on back3After the new route renders, set document.title.Assistive technology reports the page4Move focus to the heading with preventScroll.Tab order continues into the new page5Announce the page name in a polite live region.Reliable across screen readers6On back navigation, restore focus to the remembered element.
Same logic with or without animation.

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.

Focus targets after a route changeHeading or main landmark are the reliable choices; the trigger element is for back navigation.Focus targets after a route changeGood forCaveatMain heading withtabindex=-1Most forward navigationsNeeds a heading on every routemain landmarkRoutes without a single headingAnnouncement is less specificSkip link targetSites already using oneDuplicates the heading patternTriggering elementBack navigationElement may no longer existdocument.bodyNothingLoses the user's position
Heading or main landmark are the reliable choices; the trigger element is for back navigation.

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; preventScroll plus 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.