Accessible Transitions: Focus & Announcements

The View Transitions API and scroll-driven animation are both purely visual. They change what is painted, and they change nothing about the accessibility tree, the focus position or what a screen reader is told. That is a reasonable design — but it means a route change that feels beautifully continuous to a sighted reader can be completely silent to someone using a screen reader, with focus still resting on a link that no longer exists. This topic covers the obligations the animation does not discharge. It sits under accessibility and inclusive motion standards.

Guides in this topic

What the transition tells assistive technology A route change with a view transition produces four observable events for a sighted user: the old page, the morph, the new page, and the new content. For a screen-reader user it produces one: the DOM changed. Focus does not move, nothing is announced, and the animation is not perceivable at all. Sighted experience old page morph new page reads the new content Screen-reader experience the DOM changed — silently, with focus still where it was The transition is a visual effect layered over a DOM change. It carries no semantics, moves no focus and announces nothing — which means every one of those obligations is still yours. This is not a shortcoming of the API. It is what a purely visual API looks like from the accessibility tree.

Syntax reference

The relevant surface is small and none of it is animation-specific:

<!-- a focus target that is not otherwise focusable -->
<h1 tabindex="-1" id="page-title">Route title</h1>

<!-- a polite live region, present in the DOM from the start -->
<div aria-live="polite" aria-atomic="true" class="visually-hidden" id="status"></div>

<!-- a scrollable region that keyboard users can reach -->
<div class="gallery" tabindex="0" role="group" aria-label="Featured projects"></div>
// move focus after the transition has finished, not before
const t = document.startViewTransition(() => commit(nextState));
await t.finished;
document.getElementById('page-title').focus();

Three rules cover almost every case. A live region must exist in the DOM before the text is put into it, or the insertion is not observed. Focus must move to something that makes sense as a landing point, which is nearly always the new heading. And the two together are usually redundant — a focus move to a heading is itself an announcement.

Minimal working example

A client-side route change that is animated, focused and announced:

async function navigate(url) {
  const next = await fetchRoute(url);              // page still interactive

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

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

function afterNavigate() {
  const heading = document.querySelector('main h1');
  heading.setAttribute('tabindex', '-1');
  heading.focus();                                  // announces the new page
  document.title = heading.textContent;             // for the browser and history
}
The order that works Five steps in sequence: fetch the data, start the transition, commit the DOM inside the callback, move focus to the new heading once the transition finishes, and announce the change if the focus move is not itself sufficient. Moving focus before the transition completes is the common mistake. 1. Fetch the destination data — the page is still interactive here 2. Start the transition with already-resolved data 3. Commit the DOM inside the callback and return 4. Await finished, then move focus to the new heading 5. Announce only if the focus move does not already communicate the change

Timeline scoping and the accessibility tree

Scroll-driven animation raises a different question from transitions: nothing is being inserted or removed, so nothing has changed for a screen-reader user at all. An element that fades in as it enters the viewport was in the accessibility tree before it was visible, and its text was reachable by a screen reader the whole time.

That has two consequences. First, reveals need no announcement — announcing them produces a stream of noise about content the user could already read. Second, and more importantly, a reveal must not hide content from assistive technology while it is invisible. opacity: 0 and transform both leave the element in the tree, which is correct. visibility: hidden and display: none do not, which turns a visual effect into a content-availability problem.

Compositor-safe properties and the tree

The compositor-safe set is also, conveniently, the set that leaves the accessibility tree alone. transform, opacity and filter change painting without changing whether an element is exposed. This is not a coincidence — they are compositor-safe precisely because they do not affect layout or the box tree.

The properties that break both are the same properties: display, visibility, content-visibility: hidden and anything that removes the element from layout. Avoiding them in animation is a performance rule and an accessibility rule at the same time.

Common implementation patterns

Pattern 1 — focus the heading after the transition. The default for any route change. It announces the new page, gives keyboard users a sensible starting point, and needs no live region.

Pattern 2 — a polite status region for background changes. For anything that updates without the user navigating: a filtered list, an appended batch of results, a saved state.

Announce, or stay quiet? Two cases. Content that was always in the DOM and merely faded in needs no announcement — it was already available to a screen reader before it became visible. Content that is inserted into the DOM as a result of scrolling is new information and does need one. Already in the DOM a reveal changes opacity or transform the text was readable all along announcing it is noise say nothing Inserted on scroll infinite scroll, lazily-loaded results genuinely new information announce the count, politely once per batch, not per item The question is never "did something animate" — it is "did the available information change".

Pattern 3 — focus-visible-aware scrollable regions. A gallery or pinned section that is reachable, labelled and keyboard-scrollable, independent of any animation it carries.

Pattern 4 — reduced-motion parity. Whatever the animated path announces, the reduced-motion path must announce identically. A skip that replaces a transition must not also skip the focus move.

Browser support and @supports guard

None of this needs a feature query, which is the point: focus management and live regions predate every animation API on this site by a decade and work everywhere. The only guarded part is the transition itself.

if (!document.startViewTransition) {
  commit(next);
  afterNavigate();          // the same accessibility work, unconditionally
  return;
}

The failure to avoid is putting afterNavigate() only inside the transition branch, which leaves every unsupported engine without a focus move.

Gotchas and failure modes

  1. Focus moved before the transition finishes. The browser scrolls the focus target into view, which fights the animation and can produce a visible jump. Await finished first.

  2. A live region inserted along with its text. Regions must exist before the content changes, or the change is not observed. Render them in the initial markup.

  3. Announcing scroll reveals. The content was already available. Announcing it is noise, and on a page with many reveals it is disabling noise.

  4. visibility: hidden in a reveal’s initial state. It removes the element from the accessibility tree, so the content is unavailable until it animates — which for a reduced-motion user may be never.

  5. The reduced-motion path skipping more than the animation. A skip branch that returns early misses the focus move, which is the one part that was never about motion.

  6. Focus lost to a removed element. When the DOM is replaced, focus falls back to body and the reader’s position is lost. Moving it deliberately is the fix; hoping is not.

Performance checklist

  • Focus moves after the transition’s finished promise resolves, on every path including the unsupported one.
  • Live regions exist in the initial markup and are updated, never inserted.
  • Reveals use opacity and transform, never visibility or display.
  • Scrollable regions are focusable and labelled.
  • The reduced-motion path performs the same focus and announcement work as the animated one.
  • A short screen-reader pass is part of the release checklist, not an annual event.

Why animation APIs leave this to you

It is tempting to read the silence of a view transition as an oversight, and understanding why it is not makes the obligations easier to reason about.

A view transition is defined as an effect over two rendered states. It has no idea what the change meant — whether the reader navigated, filtered a list, expanded a panel, or watched a background refresh complete. Those have completely different accessibility consequences: a navigation should move focus and announce a new page, a filter should announce a result count and leave focus alone, and a background refresh should probably say nothing at all. An API that guessed would be wrong most of the time, and wrong in a way that could not be corrected.

The same argument applies to scroll-driven animation, more strongly. A reveal changes nothing about what is available — the element and its text were in the accessibility tree before, during and after. There is genuinely nothing to announce, and an API that announced anyway would make every scroll-heavy page unusable with a screen reader.

What this means practically is that the accessibility work belongs at the layer that knows the intent: your router, your filter handler, your data layer. That is also the layer that already knows whether the change succeeded, what it produced, and where the reader’s attention should go — so the work is usually a few lines in a place that already exists rather than a new system.

The corollary is the failure mode this topic keeps returning to. Because the work lives in application code and the animation lives in CSS, it is easy to add the animation and never add the rest, and easy for the two to diverge afterwards when a new route or a reduced-motion branch is added. Keeping the focus move and the announcement in one function that every navigation path calls — rather than inline in each — is what stops that drift.

The four obligations, in priority order

When there is not time to do everything, these are worth doing in this order.

Focus, first. A route change that leaves focus on a removed element leaves a keyboard user stranded: the next Tab jumps to the start of the document, and a screen-reader user hears nothing about where they now are. Moving focus to the new heading fixes the most disorienting failure and, as a side effect, produces an announcement. It is one line and it does most of the work.

Content availability, second. An element hidden from the accessibility tree by a reveal’s initial state is content nobody can reach — worse than an unannounced change, because the information is not merely unmentioned but absent. Auditing reveal initial states for visibility and display is quick and the payoff is large.

Keyboard reachability, third. Scrollable regions that cannot be scrolled without a pointer make content unreachable in the same way. Galleries, pinned sections and any container with its own overflow need tabindex and a name.

Announcements, last. Live regions are the most fiddly of the four and the least often necessary, because a focus move usually covers the same ground. They matter for changes the user did not initiate — appended results, background updates, filter counts — and for those they matter a great deal.

The ordering is deliberate: the first three are structural and hard to get wrong once done, while announcements are easy to overdo and produce their own accessibility problems when they are.

Frequently Asked Questions

Does moving focus scroll the page unexpectedly?

It can, which is why the focus move belongs after the transition completes rather than during it. Once the transition has finished, the browser scrolling the focus target into view is correct behaviour — the reader is being taken to the start of the new content. Where the target is already at the top of the viewport, nothing moves at all.

If a focus move produces a visible jump on a page where it should not, the usual cause is that the target is not the element you expected: focusing a wrapper rather than the heading, or a heading that sits below a large hero.

Should the live region be polite or assertive?

Polite, almost always. Assertive interrupts whatever the screen reader is currently saying, which is appropriate for errors and genuinely urgent state and hostile for anything else. A filtered result count, an appended batch and a saved indicator are all polite.

The rare assertive case is a change that invalidates what the user is currently doing — a session expiring, a form failing to submit. If you are reaching for assertive for anything animated, it is probably the wrong choice.

Do I need both a focus move and an announcement?

Usually not. A focus move to a heading causes the screen reader to read that heading, which is the announcement. Adding a live-region message on top produces the page name twice.

The case for both is when the change includes information the heading does not carry — a result count, an error, a filter that produced nothing. Then the heading announces where you are and the region announces what happened.

How does this interact with reduced motion?

It does not, and that is the point worth being explicit about. Focus management and announcements are not motion; they are how the change is communicated. A reduced-motion branch that skips the transition must still do all of it, and a branch written as an early return is exactly how that gets missed.

What about cross-document transitions?

They are simpler in one respect and harder in another. Simpler because a real document navigation moves focus and resets the reading position by default, so the browser does much of the work — a screen reader announces the new document without any help. Harder because your control over the moment the new page is ready is limited to what the server and the markup provide, so a focus move to a specific heading has to be arranged in the destination document rather than in the navigation code.

The practical approach is to let the browser’s default behaviour stand unless the destination has a specific landing point that is better than the top of the document, and to make sure the destination’s heading structure is good enough that the default is genuinely acceptable.

Is there a way to test focus behaviour automatically?

Partly. A browser test can assert that document.activeElement after a navigation is the expected heading, which catches the most common regression cheaply and is worth adding to whatever suite already exists. It cannot tell you whether the landing point makes sense to a person, or whether an announcement was useful rather than merely present — those need a human pass, which is why the screen-reader testing guide keeps that pass short enough to actually happen.

Does any of this change if the site has no view transitions at all?

No. Every obligation on this page exists for any client-side route change, animated or not. The transition simply makes the gap more visible, because a navigation that looks polished and communicates nothing is a sharper contrast than one that looks abrupt and communicates nothing. Sites adding transitions frequently discover their focus management was already missing.


Up: Accessibility & Inclusive Motion Standards