Managing Focus After a View Transition

When a client-side route change replaces the DOM, whatever had focus is destroyed with it and focus falls back to document.body. For a mouse user this is invisible. For a keyboard user the next Tab jumps to the top of the page, and for a screen-reader user nothing at all is announced — the page has silently become a different page. This guide covers where focus should go instead, when to move it, and how to make sure every navigation path does it. It sits under accessible transitions.

When to use this approach

  • Every client-side route change, animated or not. The transition makes the omission more noticeable, not more likely.
  • Any interaction that replaces the main content region — a filter that rebuilds a results list, a tab that swaps a panel, a step change in a multi-page form.
  • Modal open and close. The same rules apply, with the addition that focus must return to the trigger on close.

You do not need an explicit move when the change is additive and the focused element survives — appending results to a list, updating a counter, refreshing a background region. Moving focus in those cases is disruptive rather than helpful.

Focus after a route change Without an explicit move, the element focus was on is removed with the old DOM and focus falls back to the document body — so the next Tab starts from the top of the page and a screen reader announces nothing. With an explicit move to the new heading, focus lands where reading should begin. No explicit move link focused link removed focus falls back to body next Tab starts from the top of the document; nothing is announced Explicit move link focused transition finishes focus on the new heading the heading is announced, and Tab continues from the start of the new content One line of code, and it is the single highest-value accessibility fix for an animated route change.

Implementation

1. Choose the landing target

Where should focus land? Four candidates. The new page heading is almost always right. A skip-to-content wrapper is acceptable when there is no single heading. The first interactive element is usually wrong because it skips the content. And leaving focus alone is wrong whenever the previously focused element no longer exists. The new page heading announces where you are and starts reading at the right place The main landmark acceptable when there is no single heading to land on The first interactive element usually wrong — it skips past the content the reader came for Leaving focus where it was — wrong whenever that element is gone

The new page’s h1 is the right answer in almost every case: it names where the reader now is, it sits at the start of the content, and focusing it produces an announcement without any live region.

2. Make it focusable without adding it to the tab order

<h1 id="route-title" tabindex="-1">Coastal walks in the Hebrides</h1>

tabindex="-1" makes an element programmatically focusable while leaving it out of the Tab sequence, which is exactly what a landing target needs.

3. Move focus after the transition finishes

Timing the focus move Two orderings. Focusing inside the callback happens while the snapshots are being taken, so the browser scrolls the target into view during the transition and the animation visibly jumps. Focusing after the finished promise resolves puts the scroll adjustment after the animation, where it is invisible. Inside the callback commit DOM focus + scroll animation — from a moved page After finished commit DOM animation focus + scroll The scroll adjustment happens either way; the only question is whether the reader sees it fighting the animation or arriving cleanly after it.
async function navigate(url) {
  const next = await fetchRoute(url);

  if (!document.startViewTransition) {
    commit(next);
    return landOnHeading();
  }

  const transition = document.startViewTransition(() => commit(next));
  await transition.finished;
  landOnHeading();
}

function landOnHeading() {
  const h = document.querySelector('main h1');
  if (!h) return;
  h.setAttribute('tabindex', '-1');
  h.focus({ preventScroll: false });
  document.title = h.textContent.trim();
}

Awaiting finished rather than ready matters: ready resolves when the pseudo-element tree has been created and the animation is about to start, so focusing there scrolls the page mid-animation.

4. Suppress the focus ring when the navigation came from a pointer

h1:focus:not(:focus-visible) { outline: none; }

A focus ring on a heading is meaningful for a keyboard user and confusing for someone who clicked a link. :focus-visible draws the distinction the browser already knows how to make.

5. Keep every path calling the same function

The most common regression is a new navigation path — a back-button handler, a redirect, a form submission — that commits the DOM without landing focus. One function called from every path is the structural fix; remembering is not.

Verification

Navigate with the keyboard only. After a route change, press Tab once and confirm the next focus stop is inside the new content rather than at the top of the page. This is the fastest check and it catches the whole class of failure.

Turn on a screen reader and navigate. The new page’s heading should be announced within a second or so of the transition finishing, with no second announcement of the same text.

Test the browser back button specifically. Popstate handling is the path most often missing the focus move, because it is added later than forward navigation and tested less.

Finally, disable transitions — either by emulating reduced motion or by testing in an engine without support — and confirm the focus behaviour is identical. A branch that returns early before landing focus is the second most common regression here.

Edge cases and gotchas

Focusing ready instead of finished. The transition is still animating, so the browser’s scroll-into-view fights it and produces a visible jump.

A heading below a large hero. Focusing it scrolls the hero out of view immediately, which can feel like the page skipped its own opening. Either land on the main landmark instead, or accept the scroll — but check which reads better rather than assuming.

Multiple h1 elements. querySelector('main h1') picks the first, which may not be the one you meant on a page with sectioned content. Prefer an explicit id on the intended target.

Focus moved before the DOM is committed. In frameworks with asynchronous rendering the heading may not exist yet when the callback returns. Awaiting finished also solves this, since the DOM is definitionally settled by then.

Modals inside a transitioned route. Closing a modal must return focus to its trigger, and if the route also changed, the trigger may no longer exist. Falling back to the route heading is the safe default.

Browser-specific notes

focus() and tabindex="-1" behave identically in every engine and have done for many years, so nothing on this page needs a capability guard beyond the transition itself.

Chromium and Safari both resolve finished after the pseudo-element tree is torn down, which is the moment you want. Both also scroll a newly focused element into view with the same behaviour, so the timing advice holds equally.

Firefox, where same-document transitions are behind a flag in current stable builds, takes the unsupported branch by default — which makes it a useful place to verify that the branch does the same accessibility work. If focus behaviour differs between Firefox and Chromium on your site, the branch is the thing to look at.

Screen readers themselves differ more than the engines do. VoiceOver, NVDA and JAWS each have slightly different heuristics for announcing a focused heading, but all three announce it; none of them announces an unfocused DOM change.

Frequently Asked Questions

Should focus move on every navigation, including back and forward?

Yes. A back navigation replaces the content just as a forward one does, and the reader needs the same orientation. The one refinement worth making is restoring the previous scroll position first and then landing focus, so a reader returning to a long page does not lose their place.

Does preventScroll: true help?

Occasionally, and it should be used deliberately rather than defensively. Preventing the scroll keeps the viewport where it is, which is right when you have already restored a scroll position and wrong when the reader has arrived at a new page and should be at the top of it.

What if the new route has no heading?

Add one — a route without a heading is an accessibility problem in its own right. If that genuinely is not possible, focus the main landmark with tabindex="-1", which at least places the reader at the start of the content even though it announces less.

Does this apply to cross-document transitions?

Less so, because a real document navigation resets focus and the reading position by default and announces the new document. The work there is making sure the destination’s heading structure is good enough that the default landing point is useful, rather than intervening in the navigation itself.

Should the focus move be announced differently for a filter than for a navigation?

Yes, because they are different events. A navigation has taken the reader somewhere; landing on the new heading says so precisely. A filter has changed what is on the current page; the reader has not gone anywhere, so moving focus to a heading implies a navigation that did not happen.

For a filter, the better pattern is to leave focus on the control the reader is using and announce the result count in a live region. That keeps them where they are, which is where they want to be if they are about to adjust the filter again.

How does this interact with scroll restoration?

Restore the scroll position first, then land focus with preventScroll: true. Done in the other order, the focus move scrolls to the heading and the restoration then jumps somewhere else, which produces two visible movements for one navigation. Done correctly, the reader returns exactly where they were and focus is somewhere sensible without the viewport moving at all.


Up: Accessible Transitions: Focus & Announcements