Building Reduced-Motion Variants of View Transitions
Default view transitions — and most custom ones — animate transform: pages slide sideways, cards scale up into detail views, shared elements fly across the viewport. Every one of those is a displacement animation, and displacement at route-change scale is a vestibular trigger. The fix is not to abandon transitions but to swap the animation set: under prefers-reduced-motion, replace slides and scales with a short crossfade that preserves the visual continuity a transition provides while removing all movement. This page builds that variant with pseudo-element overrides and a JavaScript guard, applying the substitution rules from the vestibular-safe motion design guide within the Accessibility & Inclusive Motion Standards framework.
When to use this approach
A reduced-motion variant is one of three possible responses to the preference; pick deliberately:
- Short crossfade (this page) — The right default for SPA page-swap animations. A 150ms opacity-only fade signals “the page changed” without displacement, and avoids the disorientation of a hard cut. Opacity is not a vestibular trigger; the crossfade is a genuine reduced-motion experience, not a diluted one.
- Full skip (instant swap) — Better when transitions are purely decorative and the DOM swap is already announced to assistive technology. The prefers-reduced-motion implementation guide covers the skip path with its ARIA live-region and focus-management requirements — those requirements apply here too if you choose the skip.
- Keep the transition unchanged — Only defensible when the default transition already contains no displacement (a crossfade-only design). Then
prefers-reduced-motionneeds no variant at all, though shortening the duration is still a courtesy.
Choose the crossfade when your default transitions use translate, scale, or shared-element morphs — which is nearly every custom transition, since the browser’s own default for the root is already a crossfade and customization usually means adding movement to it.
Implementation
Step 1: Locate the displacement in your default transitions
When document.startViewTransition() runs, the browser snapshots the outgoing state, swaps the DOM, and builds a pseudo-element tree — ::view-transition-group() positions each snapshot pair, ::view-transition-old() holds the outgoing image, ::view-transition-new() the incoming one. The under-the-hood view transition walkthrough details the lifecycle; what matters here is where movement comes from. There are two sources:
/* Source 1: custom keyframes you wrote — explicit displacement */
::view-transition-old(root) {
animation: 350ms ease-in both slide-out-left;
}
::view-transition-new(root) {
animation: 350ms ease-out both slide-in-right;
}
@keyframes slide-out-left { to { transform: translateX(-100%); opacity: 0; } }
@keyframes slide-in-right { from { transform: translateX(100%); opacity: 0; } }
/* Source 2: implicit group movement — no keyframes needed */
.card { view-transition-name: card-hero; }
/* If the card's size or position differs between states, the browser
auto-generates a transform animation on ::view-transition-group(card-hero) */
Source 2 is the one teams forget: any element with a view-transition-name whose geometry changes across the swap gets a browser-generated transform animation on its group — a fly-across-the-viewport morph you never wrote in a stylesheet. Inventory both sources: grep for ::view-transition keyframes, and list every view-transition-name whose element moves or resizes between routes.
Step 2: Override the pseudo-elements with a crossfade inside the media query
The variant lives entirely in one @media block. It must address all three pseudo-element layers — old and new for your explicit keyframes, and group for the browser-generated morphs:
@media (prefers-reduced-motion: reduce) {
/* 1. Replace explicit slide/scale keyframes with a short fade */
::view-transition-old(root) {
animation: 150ms ease-out both vt-fade-out;
}
::view-transition-new(root) {
animation: 150ms ease-in both vt-fade-in;
}
/* 2. Kill browser-generated morph movement on every named group.
The group keeps the NEW geometry instantly; contents just fade. */
::view-transition-group(*) {
animation-duration: 0.01ms !important;
}
/* 3. Named snapshots fade instead of flying */
::view-transition-old(*) {
animation: 150ms ease-out both vt-fade-out;
}
::view-transition-new(*) {
animation: 150ms ease-in both vt-fade-in;
}
}
@keyframes vt-fade-out { to { opacity: 0; } }
@keyframes vt-fade-in { from { opacity: 0; } }
Design decisions worth noting:
- 150ms, not 0.01ms, on
old/new. The near-zero duration is the skip pattern; here the fade is the feature. 100–200ms is long enough to register as continuity, short enough that users who chose reduced motion are not made to wait. 0.01msrather than0mson the group. Zero-duration animations skipanimationendin some engines, which can stall code awaitingtransition.finished. The near-zero value collapses the movement while keeping the event pipeline intact.- The
(*)selectors are load-bearing. Overriding only(root)leaves every named shared element still morphing across the screen — the highest-displacement animations survive the “fix”. The universal argument catches names added later by other teams, which makes the variant maintenance-proof. - Keyframes are defined outside the media query. Only the assignment is conditional; keeping
@keyframesunconditional avoids duplicate-name collisions if you also reference the fades elsewhere.
Step 3: Guard startViewTransition in JavaScript
CSS handles the animation swap, but two cases need a JS guard: transitions that pass explicit animation options via the Web Animations API, and apps that should skip transitions entirely for other reasons (data-saver, low battery). Centralize the decision in one wrapper rather than at each call site — for routed apps, the React Router view transition integration shows where this wrapper plugs into navigation:
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function transitionTo(updateDOM) {
// No API support: plain swap (also covers engines without the feature)
if (!document.startViewTransition) {
updateDOM();
return;
}
const vt = document.startViewTransition(updateDOM);
if (reducedMotion.matches) {
// CSS already swapped the animations to a 150ms crossfade.
// If you drive animations via vt.ready + element.animate(),
// branch here to an opacity-only WAAPI fade instead.
vt.ready.catch(() => { /* transition was skipped — swap still applied */ });
}
return vt;
}
Read reducedMotion.matches at call time (or subscribe to its change event), never cache it at module load — users toggle the OS setting mid-session. And note what this guard deliberately does not do: it still calls startViewTransition() when motion is reduced, because the crossfade variant needs the snapshot machinery. Only choose the skip path (updateDOM() without the API) if you have decided against a crossfade — in that case the instant swap needs the ARIA live-region announcement covered in the reduced-motion CSS reference.
For cross-document (multi-page) transitions, the same CSS from Step 2 applies on both pages, and the opt-in itself can be made conditional so reduced-motion users never enter the transition pipeline at all:
/* Cross-document opt-in, withheld under reduced motion (Chrome 126+, Safari 18.2+) */
@view-transition {
navigation: auto;
}
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none; /* hard cut between pages; or keep auto + crossfade CSS */
}
}
Keeping navigation: auto and relying on the Step 2 crossfade is equally valid — withhold the opt-in only when you prefer the instant swap.
Step 4: Keep the variant honest as transitions evolve
The crossfade variant fails silently: if a teammate adds a new named transition with a dramatic morph, the default experience changes visibly while the reduced-motion path only stays correct if the (*) overrides still win the cascade. Two cheap safeguards: keep all ::view-transition rules in a single stylesheet module so the media-query block is adjacent to what it overrides, and add a lint or review rule that any new view-transition-name needs a corresponding entry in the transition inventory from Step 1. Specificity matters here — ::view-transition-old(card-hero) written after the media block, or with !important, beats the (*) override; the safest convention is that named per-element animations never use !important and the reduced-motion block is imported last.
Verification
- Emulate the preference: DevTools → Rendering → Emulate CSS media feature
prefers-reduced-motion→reduce. Trigger every route change and shared-element transition in the app. - Watch for displacement: nothing may slide, scale, or morph across the viewport. If any element still flies, a named group is escaping the
(*)override — inspect it in the Animations drawer while the transition runs. - Inspect the pseudo-tree: during a transition, the Elements panel shows the
::view-transitiontree on<html>. Select each::view-transition-group()and confirm its computedanimation-durationis0.01msand eachold/newruns only the fade keyframes. - Assert durations programmatically:
// Run during an in-flight transition with reduced motion emulated
const anims = document.getAnimations();
const moving = anims.filter(a => {
const kf = a.effect.getKeyframes();
return kf.some(f => f.transform && f.transform !== 'none');
});
console.assert(moving.length === 0, 'transform animations survived reduced motion', moving);
- Automated coverage: Playwright’s
page.emulateMedia({ reducedMotion: 'reduce' })before navigation, then assert the check above returns zero and thattransition.finishedresolves (catches the0ms/animationendstall). - Screen-reader pass: whichever variant you chose, confirm the route change is perceivable — crossfade variants keep focus behavior identical to the default path; skip variants need the live-region announcement verified by ear.
Edge cases and gotchas
view-transition-name collisions abort the whole transition. If two elements share a name during the swap, the transition is skipped and the update applies instantly — in both the default and reduced paths. Your crossfade cannot rescue a transition the browser refused to run; watch for vt.ready rejections in monitoring.
Scroll position changes masquerade as transition motion. If navigation restores a different scroll position, the root snapshot pair differs by a large vertical offset, and the browser’s geometry animation renders it as a full-viewport slide — even with your fade keyframes on old/new, the group carries the movement. The ::view-transition-group(*) duration override in Step 2 is what suppresses this; do not trim it as an optimization.
WAAPI-driven transition animations bypass the media query. Animations started from vt.ready with element.animate() are not touched by CSS overrides at all. Every such call needs the JS branch from Step 3. This is the most common leak in codebases that mix CSS and scripted transition choreography.
Snapshot memory is unchanged by the variant. The crossfade still allocates snapshot textures for every named element. On snapshot-heavy pages this is a performance cost reduced-motion users pay without seeing the payoff of morphs; keep the set of view-transition-names minimal so both paths stay cheap. If profiling shows transition jank, that is a performance defect to fix for everyone — the view transition rendering internals explain where snapshot costs accrue.
The default root crossfade is a reasonable floor. If you strip all customization inside the media query (animation: revert on old/new pseudo-elements for named groups), the browser’s built-in behavior is itself a fade — but its default duration and the group geometry animation still apply, so explicit overrides remain the predictable route.
Vestibular safety also constrains the default path. A reduced-motion variant does not license unlimited movement for everyone else; the displacement caps from the parent guide and the animation-from-interaction requirements in the WCAG 2.2 animation compliance criteria apply to the default transition design too.
Browser-specific notes
Chrome / Edge (111+ same-document, 126+ cross-document)
Full support for the pseudo-element tree, @view-transition, and nested @media around it. DevTools’ Animations drawer can slow a transition to 10% speed — the most reliable way to see whether any displacement survives the override.
Safari (18+ same-document, 18.2+ cross-document)
Supports the same pseudo-element selectors and honors the media query. Verify the 0.01ms group override behaves — WebKit has historically been the engine most sensitive to zero-duration animationend edge cases, which is why the near-zero convention exists.
Firefox
Same-document view transitions are in development (arriving around Firefox 144); in current stable releases document.startViewTransition is undefined, so the Step 3 guard’s plain-swap branch runs. That branch is exactly the skip pattern — meaning Firefox users get an instant swap today, and your live-region/focus handling gets exercised in production whether or not you intended it. Feature-detect rather than UA-sniff, and see the same-document vs cross-document breakdown for the current per-mode landscape.
All engines
prefers-reduced-motion support long predates view transitions, so the media query itself never needs a guard. The pseudo-element rules are simply inert where the API is absent — safe to ship unconditionally.
Related
- Designing Parallax Effects Safe for Vestibular Disorders — the companion retrofit for scroll-linked displacement
- Motion Scaling & User Preferences — proportional motion reduction for users who want less, not none
- View Transition API vs FLIP Technique Comparison — how reduced-motion handling differs when transitions are hand-rolled with FLIP
- SPA Page-Swap Animations — the transition patterns these variants are built for
- Implementing prefers-reduced-motion in Scroll-Driven Animations — the full override architecture, ARIA announcements, and focus management for the skip path