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.

Three ways to respond to the preferenceOnly the third keeps the coordination benefits the API provides.Three ways to respond to the preferenceSkip the API entirelyNo freeze during the DOMupdateIntermediate frames canpaintScroll restoration isyours to handleTwo divergent code pathsto maintainWorse for the users ittargetsCall skipTransition()Transition object stillcreatedSnapshot tree torn downmid-flightAbrupt cut where a fadewas expectedPromises settle earlyBlunt, but recoverableNeutralise in CSSFreeze and coordinationpreservedOne code path for bothpreferencesGate lives with theother motion rulesCovers types added laterKeep the machinery, dropthe travel
Only the third keeps the coordination benefits the API provides.

Step-by-step resolution

Gating a transition properlyThe whole change is in the stylesheet; the router code is identical for both preferences.Gating a transition properly1Leave the startViewTransition call unconditional so every user gets thefreeze.No intermediate frames for anyone2Write the reduced-motion block after every typed rule so it wins thecascade.Later types cannot escape the gate3Target ::view-transition-group(*) and the old and new pseudo-elements witha wildcard.One rule covers every named element4Replace the directional keyframes with a short opacity fade.No travel, and the change is still perceptible5Verify the scroll position matches the animated path.Both branches end in the same place
The whole change is in the stylesheet; the router code is identical for both preferences.

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: opacity only 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 and finished settles 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.

What each pseudo-element should do under the gateNeutralise the containers, fade the images, and leave nothing that travels.What each pseudo-element should do under the gateDefaultUnder reduce::view-transition-group(*)Interpolates position and size1ms — snap to the new box::view-transition-image-pair(*)Holds both snapshots1ms::view-transition-old(*)Fades out, may translateShort opacity fade only::view-transition-new(*)Fades in, may translateShort opacity fade onlyTyped directionalkeyframesSlide in a directionOverridden entirely
Neutralise the containers, fade the images, and leave nothing that travels.

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.