Honouring prefers-reduced-motion in View Transitions
A shared-element morph is exactly the kind of motion that causes trouble. A large object travels a long distance across the viewport while changing size — high velocity, large screen area, and a scale change all at once. For a user with a vestibular disorder that combination is not a nice touch; it is a symptom trigger. The obvious response is to bolt a prefers-reduced-motion media query onto the animations and set them to none, and that response produces a worse experience than doing nothing. This page, part of View Transitions for CSS Developers, explains why, and what to write instead.
The narrow problem: you have a working transition with travel in it, and you need a version of it that satisfies a reduced-motion request without turning the interface into a series of hard cuts.
Why animation: none is the wrong instinct
The preference is not a request to remove feedback. It is a request to remove movement — the WCAG guidance behind it targets motion that implies physical displacement, not changes of state as such. Removing the animation removes the movement, so on the face of it the override looks correct:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}
The catch is that this cancels the animations but not the transition. The browser still captures both states, still builds the overlay, still creates a group for every named element, and still has to reconcile two different measured rectangles. What it no longer has is a smooth path between them. The snapshot is placed at its start geometry on one frame and its end geometry on the next, so the element teleports — a full-size jump completed in 16 milliseconds. Instantaneous large-area change is precisely what a startle response is tuned to detect. You have replaced a 400ms glide with a single-frame snap, which is more alarming, not less.
There is a second, subtler failure. animation: none on the snapshots leaves the group animation untouched, because the group is a separate pseudo-element with its own generated animation. So the box still resizes over 250ms while the images inside it are frozen at full opacity, producing a stretched, sliding rectangle with no cross-fade to disguise it. If you are going to override, you have to override the group too, or you get the worst of both.
The approach: substitute, do not subtract
The reliable pattern is to treat reduced motion as a different design, not a disabled one. A cross-fade satisfies the preference on every axis that matters — nothing translates, nothing scales, nothing rotates, and the area of the screen in motion is zero — while still telling the user that one state became another over a measurable span of time.
The demo below puts the two side by side: the travelling version on the left, the substitute on the right. Both are timed; only one moves.
Complete working implementation
The full override for a page that morphs a thumbnail into a hero. Three things have to be neutralised — the group's geometry animation, any custom snapshot keyframes, and the default cross-fade's duration — and one thing has to be preserved: a fade.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Reduced-motion view transition</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
.hero { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 12px; }
.panel { padding: 1.5rem; border: 1px solid currentColor; border-radius: 12px; }
/* ---- Full-motion design (the default) ---------------------------- */
@keyframes vt-rise {
from { opacity: 0; transform: translateY(24px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes vt-sink {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-16px) scale(0.98); }
}
::view-transition-new(hero-shot) {
animation: vt-rise 420ms cubic-bezier(0.32, 0.72, 0, 1) both;
}
::view-transition-old(hero-shot) {
animation: vt-sink 420ms cubic-bezier(0.32, 0.72, 0, 1) both;
}
::view-transition-group(hero-shot) {
animation-duration: 420ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
/* ---- Reduced-motion design (the substitute) ---------------------- */
@media (prefers-reduced-motion: reduce) {
/* 1. Replace the travelling keyframes with a pure opacity pair.
Explicit keyframes, NOT 'animation: none' — cancelling here
leaves the snapshot to jump between two geometries. */
@keyframes vt-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes vt-fade-out { from { opacity: 1; } to { opacity: 0; } }
::view-transition-new(*) {
animation: vt-fade-in 160ms linear both;
}
::view-transition-old(*) {
animation: vt-fade-out 160ms linear both;
}
/* 2. Collapse the group's geometry animation. Duration 0 makes the
box adopt its final rectangle immediately, so the fade happens
in place at the destination size instead of gliding into it. */
::view-transition-group(*) {
animation-duration: 0s;
}
/* 3. Keep an opaque backdrop so the in-place fade never shows the
live DOM through two semi-transparent snapshots. */
::view-transition { background-color: Canvas; }
}
</style>
</head>
<body>
<img class="hero" id="hero" src="/img/7.jpg" alt="Coastal path">
<div class="panel" id="panel"><p>Coastal path, north shore.</p></div>
<button type="button" id="next">Next photo</button>
<script>
const hero = document.getElementById('hero');
const panel = document.getElementById('panel');
const shots = [
{ src: '/img/7.jpg', alt: 'Coastal path', text: 'Coastal path, north shore.' },
{ src: '/img/12.jpg', alt: 'Harbour at dusk', text: 'Harbour at dusk, low tide.' }
];
let i = 0;
function swap() {
i = (i + 1) % shots.length;
hero.src = shots[i].src;
hero.alt = shots[i].alt;
panel.innerHTML = '<p>' + shots[i].text + '</p>';
}
document.getElementById('next').addEventListener('click', () => {
hero.style.viewTransitionName = 'hero-shot';
if (!document.startViewTransition) { swap(); return; }
document.startViewTransition(swap).finished
.then(() => { hero.style.viewTransitionName = ''; });
});
</script>
</body>
</html>
Everything that makes this correct is in the media query, and it is entirely declarative — no branching in the click handler at all. The transition still runs for reduced-motion users; it simply looks different.
The key technique: zeroing the group, not the snapshots
The line doing the real work is ::view-transition-group(*) { animation-duration: 0s; }.
A group carries a generated animation interpolating width, height, and transform between the two measured rectangles. That animation is the only source of travel and scaling in a shared-element morph — the snapshots inside it never move on their own unless you tell them to. Setting its duration to zero makes the group adopt its final geometry on the first frame, which sounds like the same snap described earlier but is not, for one reason: the snapshots inside it are still cross-fading. The old image fades out at the destination size while the new one fades in, so there is no moment where a fully opaque object is seen in two different places.
Contrast that with animation: none on the snapshots, which leaves the group animating and the images opaque. The distinction is exact: zero the geometry and keep the opacity, never the other way round. Getting this backwards is the single most common reduced-motion bug in view-transition code, and it is invisible unless you test with the preference actually enabled.
The 160ms fade duration is deliberately shorter than the 420ms morph. Once there is no distance to cover, a long fade is just latency; the shorter span reads as responsive while still being long enough to register as a transition rather than a cut. The reasoning generalises across all motion work — see reducing motion preferences in CSS and the catalogue in prefers-reduced-motion recipes.
Variation: skipping the transition outright
Sometimes a fade is still too much — a data-dense table view where the transition adds nothing, or a page where the transition exists purely for delight. Because the preference is readable from script, you can decline to start the transition at all:
const wantsLessMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function run(mutate) {
if (wantsLessMotion || !document.startViewTransition) {
mutate();
return;
}
document.startViewTransition(mutate);
}
No capture happens, no overlay is created, and there is nothing to snap. This is a genuinely valid option — an instant content change is not a motion problem — but it forfeits the continuity cue entirely, so prefer it for utilitarian views and keep the cross-fade for anything where the user needs to understand that one thing became another. Note that matchMedia is read once here; if you support users toggling the preference mid-session, read .matches inside run() instead.
For the broader question of which motion warrants an alternative at all, vestibular-safe animation patterns covers the physiology, and WCAG motion success criteria covers the conformance requirements a large-area morph has to meet.
Browser support note
prefers-reduced-motion has been supported for years across Chrome, Edge, Firefox, and Safari, so the media query itself is safe everywhere. The pseudo-element rules inside it only apply where view transitions exist at all — Chrome and Edge 111+, Safari 18+, and Firefox 144+, which is every current engine. That means the reduced-motion override is not a niche branch: it now runs for essentially every user who has the preference set, so it has to be correct rather than merely present. On anything older than those versions both the transition and the override are absent, and the user gets the instant DOM change that the reduced-motion path was approximating anyway.
Test this properly rather than trusting the code. Chrome DevTools exposes the preference under Rendering → "Emulate CSS media feature prefers-reduced-motion", and Safari has the equivalent in the Web Inspector's appearance controls. Toggle it on, trigger the transition, and slow the Animations panel to 25% — a lingering geometry animation is obvious at that speed and nearly invisible at full rate.
FAQ
Why is animation: none the wrong reduced-motion override for a view transition? It cancels the animation but not the transition. The overlay is still built and the group still has to reconcile two different rectangles, so the snapshot jumps between them in a single frame, which is more startling than the motion it replaced.
Should a reduced-motion preference disable view transitions completely? Usually not. The preference asks for less movement, not less feedback. A short cross-fade keeps the sense of continuity between two states while removing all travel, scaling, and rotation.
How do I skip a view transition entirely when I do want to?
Check the preference in script with window.matchMedia and call the DOM-mutating function directly instead of passing it to document.startViewTransition. No overlay is created, so there is nothing to animate or snap.
Does prefers-reduced-motion cover the whole transition or just my custom keyframes? A media query in your stylesheet only affects rules you wrote. The user-agent default animations on the snapshots and the generated group animation still run unless your rules override them.
Related
- View Transitions for CSS Developers — the parent guide on the snapshot model and pseudo-element tree.
- View transition names and shared elements — the morph whose geometry animation this page neutralises.
- Same-document view transitions — the full-motion keyframes being substituted here.
- Vestibular-safe animation patterns — why large-area travel is the specific thing to remove.
- Fluid type accessibility and zoom — the same substitute-don't-subtract principle applied to responsive text.
Related articles
More pages in the same section.