Handling View Transitions with Reduced Motion
Part of View Transitions API Implementation in Modern View Transitions & Scroll APIs.
The problem
A page-level view transition moves the largest area of anything on the site. A full-width slide is optical flow across the entire visual field, which is precisely the pattern that triggers vestibular symptoms — and it happens at exactly the moment a user has committed to a navigation and cannot look away.
The instinct is to skip the transition when prefers-reduced-motion: reduce is set. That turns out to break more than it fixes, because the API is doing two jobs and only one of them is animation.
Root cause: the transition is a coordination mechanism, not only an effect
document.startViewTransition() does four things: it captures the old state, it freezes rendering while your callback mutates the DOM, it builds a pseudo-element tree, and it animates between the two snapshots. Only the last of those is motion.
The freeze matters. Without it, a callback that rebuilds a large part of the page can paint an intermediate frame — half the old list, half the new one, a heading updated before its content. With it, the user sees the old state, then the new state, and nothing in between. Skipping the API entirely to avoid the animation gives up that guarantee, and users with reduced motion get a worse experience than everyone else: not just less motion, but a visible flash of inconsistent content.
Calling skipTransition() has a related problem. It jumps to the end state immediately, which means the snapshot tree is torn down mid-flight; anything that was relying on the crossfade to hide a layout difference gets an abrupt cut.
The correct approach is to keep the whole mechanism and neutralise the animation in the stylesheet, where the media query already lives.
Step-by-step resolution
Production code pattern
/* Intent: full choreography by default; under reduce, the same navigation
happens with a short crossfade and no travel at all. */
html:active-view-transition-type(forward) {
&::view-transition-old(root) { animation: 200ms ease-in both slide-out; }
&::view-transition-new(root) { animation: 260ms ease-out both slide-in; }
}
@keyframes slide-out { to { translate: -28px 0; opacity: 0; } }
@keyframes slide-in { from { translate: 28px 0; opacity: 0; } }
/* Accessibility gate. Declared last, wildcard-scoped, so it also covers
groups and types introduced by future work. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-image-pair(*) {
animation-duration: 1ms;
}
::view-transition-old(*),
::view-transition-new(*) {
animation: 140ms linear both vt-fade;
/* No translate, no scale: opacity is the only channel. */
}
@keyframes vt-fade { from { opacity: 0; } }
}
// Intent: identical for both preferences — the CSS decides what it looks like.
async function navigate(url, direction) {
const view = await loadRoute(url);
if (!document.startViewTransition) { applyView(view); return; }
const transition = document.startViewTransition({
update: () => { applyView(view); restoreScroll(url); },
types: [direction],
});
await transition.finished.catch(() => {}); // skipped or aborted is fine
}
Rendering Impact:
opacityonly under the gate — composite. The group animations are reduced to a millisecond rather than removed, so the pseudo-element tree still tears down through the normal path andfinishedsettles as usual.
Restoring scroll inside the callback rather than after it is what keeps the two branches identical. The after-snapshot is captured at the end of the callback, so a scroll restored afterwards produces a snapshot at the wrong offset — which shows up as content jumping at the end of the fade, and is far more disorienting than the animation you just removed.
Verification checklist
Constraints and trade-offs
- A 1 ms group animation is not zero, so a transition still occupies a frame or two; that is deliberate, to keep the teardown path identical.
- The default crossfade applies even with no author rules, so “writing no CSS” is still a motion decision.
- Wildcard selectors also match groups you did not create, including any added by a third-party component.
- Restoring scroll outside the callback produces an end-of-transition jump that reads worse than the animation.
- If the update callback awaits a slow fetch, the frozen frame lasts as long as the fetch — prefetch before starting the transition rather than inside it.
Frequently asked questions
Should I just not call startViewTransition when reduced motion is set?
No. The API does more than animate: it freezes the page during the DOM update and coordinates scroll restoration. Skipping it entirely can expose a partially updated frame. Run the transition and neutralise the animation in CSS instead.
Is a 1ms transition the same as no transition?
Close enough visually, and safer structurally, because the promises still settle and any code awaiting finished still runs. A duration of exactly zero is also valid, but keeping a millisecond avoids edge cases where an engine treats zero as ‘no animation created’.
Does prefers-reduced-motion affect the pseudo-elements automatically?
No. The default crossfade is a real animation on ::view-transition-old and -new, and the browser does not remove it for you. If you write no rules at all, reduced-motion users still get the default crossfade — which is mild, but it is your job to decide that deliberately.
Deciding what “less motion” means for a navigation
The gate above removes travel and keeps a short crossfade, which is the right default. It is worth being explicit about why, because two other choices are defensible and one of them is not.
Removing all animation — a 1 ms crossfade as well as the slide — is defensible for a product whose navigations are frequent and short. The user still gets the atomic DOM swap; they simply get it instantly. The risk is that a fast route change with no visual acknowledgement can leave someone unsure whether their tap registered, which is a different accessibility problem.
Keeping a short crossfade, as here, gives that acknowledgement with no displacement. Opacity has no direction and no optical flow, so it carries essentially none of the vestibular risk that a slide does, and 120 to 150 milliseconds is long enough to perceive without being long enough to wait for.
Keeping a shortened slide is the one to avoid. Compressing a 260 ms slide into 80 ms does not remove the travel — it increases the velocity, and velocity is one of the factors that makes optical flow uncomfortable. Faster is not gentler.
Where a product has a genuine reason to distinguish these, expose it as a setting rather than inferring it. The OS preference says “less motion”, not “which of my three variants” — and a user who has asked for less motion has not asked to be given a choice architecture.
Related
- View Transitions API Implementation — the lifecycle and the pseudo-element tree the gate targets
- prefers-reduced-motion Architecture — the cascade this gate belongs to
- Vestibular-Safe Motion Patterns — why full-page travel is the first thing to remove