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
- Managing focus after a view transition — where focus should land, and when to move it.
- Live-region announcements for scroll-revealed content — when a reveal is news and when it is noise.
- Keyboard access to scroll-driven interfaces — scrollable regions, galleries and pinned sections without a pointer.
- Screen-reader testing for animated pages — a short repeatable pass that catches the common failures.
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
}
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.
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
-
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
finishedfirst. -
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.
-
Announcing scroll reveals. The content was already available. Announcing it is noise, and on a page with many reveals it is disabling noise.
-
visibility: hiddenin 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. -
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.
-
Focus lost to a removed element. When the DOM is replaced, focus falls back to
bodyand the reader’s position is lost. Moving it deliberately is the fix; hoping is not.
Performance checklist
- Focus moves after the transition’s
finishedpromise resolves, on every path including the unsupported one. - Live regions exist in the initial markup and are updated, never inserted.
- Reveals use
opacityandtransform, nevervisibilityordisplay. - 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.
Related
- Implementing prefers-reduced-motion — the override that must keep the accessibility work intact
- WCAG 2.2 Animation Compliance — where these obligations sit against the success criteria
- SPA Page-Swap Animations — the route change this topic is mostly about
- Cross-Route Element Morphing — the visual half of the same navigation