prefers-reduced-motion, done properly
Some people feel dizzy, nauseous or disoriented when a page moves a lot: parallax, zooming, big slides, spinning objects, scrolling that does not match the finger. Operating systems let them say so, and browsers pass the setting to your page as a media feature. Honouring it is cheap when you design the reduced version on purpose, and unreliable when you bolt on one blanket rule.
What the setting means
prefers-reduced-motion has two values: no-preference and reduce. MDN notes that @media (prefers-reduced-motion) on its own is equivalent to reduce. A visitor who selects reduce is asking for less motion, not zero change, and MDN describes the aim as removing, reducing or replacing motion-based animations. The distinction is what separates a considered reduced mode from a broken one.
| Usually remove or replace | Usually keep |
|---|---|
| Parallax and scroll-linked travel | Opacity fades |
| Large scale or zoom effects | Colour and border changes |
| Full-width slides and page-wipe transitions | Small hover and press feedback |
| Spinning, orbiting, looping decorative motion | Focus indicators |
| Auto-playing background video | Progress bars that report real progress |
| Smooth-scroll hijacking and snapping jumps | Instant jumps to a destination |
| Camera moves, tilt-to-look and cursor-chasing elements | Content appearing in place |
| Attention-seeking pulses and bounces | Layout that stays stable |
Rule of thumb: remove motion that moves things across the viewer's field of view; keep change that happens in place. A fade is a state change. A slide of 300 px is a journey. The evidence behind the rule is in the sources: MDN names scaling or panning large objects as vestibular triggers, and the WCAG Understanding page for 2.3.3 says that scrolling that makes elements move, and parallax in particular, can trigger vestibular disorders, with dizziness, nausea and headaches as the reactions.
CSS: design both paths
The most robust pattern puts the movement behind a positive check, so the default is the safe one:
.reveal { opacity: 0; transition: opacity 300ms ease-out; }
.reveal.is-in { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
.reveal { transform: translateY(24px);
transition: opacity 500ms ease-out, transform 500ms ease-out; }
.reveal.is-in { transform: none; }
}
Reduced users get the fade; everyone else gets fade plus travel. A second pattern keeps one rule set and varies values through custom properties:
:root { --travel: 24px; --scale: 1.06; }
@media (prefers-reduced-motion: reduce) {
:root { --travel: 0px; --scale: 1; }
}
Both the travel and the zoom shrink to nothing, while durations and fades keep working. web.dev adds a delivery trick: put all animation-related CSS in a separate stylesheet and load it conditionally with <link rel="stylesheet" href="animations.css" media="(prefers-reduced-motion: no-preference)">, which spares the opted-out visitor from downloading it. The same article shows a <picture> element with animated AVIF or GIF sources carrying that media condition and a static image as the fallback.
The blanket reset, and why it is a fallback
web.dev presents a "use at your own risk" snippet that shortens every animation and transition to almost nothing:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-delay: -1ms !important;
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
background-attachment: initial !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
transition-delay: -1ms !important;
}
}
The article explains the choice of a tiny duration over animation: none: some sites need an animationend event to fire to work correctly, which animation: none would suppress. It also lists the limits. The snippet is not guaranteed to succeed on every site, it cannot stop motion started with the Web Animations API, and it should be deactivated if it causes breakage. Treat it as a net under a properly designed reduced path. One failure it cannot catch: an element that starts hidden for its entrance (opacity: 0) and relies on the animation to become visible. If the animation is skipped or shortened and the final state is never applied, the content stays invisible. Define the visible state as the default and add the hidden starting state only inside no-preference.
JavaScript: read it, and keep listening
CSS cannot stop a requestAnimationFrame loop or a Web Animations API call. Read the preference with matchMedia, and subscribe to changes, because MDN's matchMedia page says a change event is delivered when the result changes, and MDN's reduced-motion page says changes to the underlying setting take effect immediately:
const query = matchMedia('(prefers-reduced-motion: reduce)');
let reduced = query.matches;
query.addEventListener('change', (event) => {
reduced = event.matches;
applyMotionMode();
});
function applyMotionMode() {
if (reduced) {
stopAmbientLoops(); // cancel rAF, pause canvases and Lottie players
video?.pause();
renderStillFrame(); // one designed frame, not an empty canvas
} else {
startAmbientLoops();
}
}
applyMotionMode();
WCAG lists exactly this as a sufficient technique for 2.3.3: SCR40, using the CSS prefers-reduced-motion query in JavaScript to prevent motion, alongside C39 for the CSS version. Three points:
- Stop, do not hide. A canvas that is invisible but still running burns battery. MDN says
requestAnimationFramecallbacks are paused in most browsers in background tabs, which covers a hidden tab, but not a canvas scrolled out of view in the visible one. Cancel the loop for reduce, and use anIntersectionObserverto stop one that is off screen. - Draw one good still. Reduced motion should show a designed frame of the scene, not a blank rectangle. For a particle field, render it once at its resting state; see particle systems in the browser.
- Libraries differ. Some read the setting for you. Lenis, for instance, documents
respectReducedMotionas on by default, so smoothing is disabled and programmatic scrolls become instant underreduce. Animation timelines, Lottie players and 3D controls may not, so check each one rather than assuming. Smooth scrolling without the regret covers the scroll case.
For video, leave the autoplay attribute out of the HTML and start playback from script only when the preference is no-preference. If the attribute is in the markup, the video can begin before your script has had a chance to read the setting. In the reduced path, show the poster frame and give the visitor a play button. A looping background video that cannot be stopped is the single most common failure.
Pause controls are separate from the setting
The media query depends on a system setting that many people never find or enable. WCAG 2.2 success criterion 2.2.2 Pause, Stop, Hide (Level A) applies to everyone. For any moving, blinking or scrolling information that starts automatically, lasts more than five seconds and is presented in parallel with other content, there must be a mechanism to pause, stop or hide it, unless the movement is part of an activity where it is essential. Auto-updating information, such as a live feed, has a similar rule with no five-second condition: a mechanism to pause, stop or hide it, or to control the frequency of the update.
So an ambient animation needs both: a default that respects the setting, and a visible control that works at any time. The control should be a real <button> with an accessible name and a state:
<button type="button" aria-pressed="false" class="motion-toggle">
Pause background animation
</button>
The WAI-ARIA Authoring Practices button pattern is explicit about the pairing: with aria-pressed, it is critical that the label does not change when the state changes. If the design calls for the label to change, as in Pause and Play, aria-pressed is not needed. Use one approach, not both, so that screen readers do not announce a contradiction. Remember the choice for the session if you can.
The two other criteria worth knowing:
- 2.3.1 Three Flashes or Below Threshold (Level A): nothing flashes more than three times in any one-second period, or the flash is below the general flash and red flash thresholds. This is about seizure risk; avoid flashing entirely.
- 2.3.3 Animation from Interactions (Level AAA): motion animation triggered by interaction can be disabled unless it is essential to the functionality or the information. The Understanding page lists a control that turns off unnecessary animations among its examples.
A worked example: one fictional hero
Take a fictional studio page with a particle canvas behind the headline, a looping background video in a card, words that reveal one by one, and smooth scrolling. The same page needs two designed paths:
| Element | Default path | Reduced path |
|---|---|---|
| Particle canvas | Animated swirl, reacts to pointer | One still frame at the resting shape, no pointer response |
| Background video | Started by script, muted, loop | Poster frame, play button, nothing starts |
| Word-by-word reveal | Each word rises 24 px and fades in | Whole headline fades in over 300 ms, no travel |
| Smooth scrolling | Library active for mouse users | Not created; native scrolling |
| Scroll-linked parallax | Layers move at different speeds | Layers fixed in place |
| Hover on cards | 6 px rise and shadow change | Border colour change only |
| Pause control | Visible beside the hero, labelled | Still present, in the paused state |
Every row still communicates the same content. Reduced mode is a different arrangement of the same page, not a degraded one. Because it is a designed path, it can also serve as the fallback for visitors on weak devices.
Test routine
Switch the OS setting. MDN lists the paths: Windows 11, Settings, Accessibility, Visual Effects, Animation Effects; macOS up to 15, System Settings, Accessibility, Display, Reduce motion (newer releases moved it to Accessibility, Motion); iOS, Settings, Accessibility, Motion; Android 9 and later, Settings, Accessibility, Remove animations. Menus are renamed between releases, so treat these as a starting point.
Emulate in DevTools. In Chrome, open the Rendering tab and choose
prefers-reduced-motion: reduceunder "Emulate CSS media feature prefers-reduced-motion". RunmatchMedia('(prefers-reduced-motion: reduce)').matchesin the console to confirm that the page sees it.Reload with reduce on, then walk the whole page: no parallax, no travel, nothing auto-playing, canvases still, all content visible and readable.
Toggle while open. Turn the setting on and off with the page loaded; loops should stop and start without a reload.
Count frames, not impressions. Paste this into the console with the page idle, toggle the setting, and compare the counts:
let frames = 0; const raf = window.requestAnimationFrame; window.requestAnimationFrame = (cb) => raf((t) => { frames++; cb(t); }); setTimeout(() => console.log('rAF callbacks in 5 s:', frames), 5000);The patch only sees loops that call
window.requestAnimationFrameeach frame, which covers the usual pattern. With reduce on and nothing moving, the count should be close to zero; a number near 300 (five seconds at 60 Hz) means a loop is still running.Keyboard. Reach the pause control with Tab, operate it with Enter and Space, and confirm that focus stays visible.
Automate it. In an end-to-end test, set the media feature before navigating, for example
page.emulateMedia({ reducedMotion: 'reduce' })in Playwright, and assert on the DOM state.
Checklist
- Movement lives inside
no-preference, or its distance variables go to zero underreduce; fades stay, and the visible state is the default. - JavaScript reads
matchMedia, listens forchange, cancels loops and shows a designed still. - Auto-playing video and ambient canvases have a visible, keyboard-operable pause control, and video is started by script only when motion is allowed.
- Nothing flashes more than three times a second.
- Test with the OS setting and DevTools emulation, on reload and live, and count frames while idle.
For the curves and durations that remain in the reduced path, see easing is a personality; for scenes that run continuously, see a performance budget for a site with WebGL.
Questions and answers
Does prefers-reduced-motion mean no animation at all?
No. MDN says the setting asks the page to remove, reduce or replace motion-based animations, and singles out scaling or panning large objects as vestibular triggers. Small fades, colour changes and state feedback can usually stay.
Is a pause button still needed if I honour the media query?
Yes for anything that moves automatically for more than five seconds alongside other content. WCAG 2.2.2 (Level A) asks for a way to pause, stop or hide it, and a visitor with a motion sensitivity may never have changed the system setting.
How do I test prefers-reduced-motion?
Turn on the operating system setting, or use the Rendering tab in Chrome DevTools to emulate the media feature, then reload and also toggle it while the page is open. Playwright can set it with page.emulateMedia({ reducedMotion: 'reduce' }).
Which WCAG criteria are involved?
2.2.2 Pause, Stop, Hide (Level A) for auto-moving content, 2.3.1 Three Flashes or Below Threshold (Level A), and 2.3.3 Animation from Interactions (Level AAA) for motion triggered by user actions.
Sources
- MDN: prefers-reduced-motion developer.mozilla.org
- web.dev: prefers-reduced-motion, sometimes less movement is more web.dev
- WCAG 2.2: Understanding Pause, Stop, Hide (2.2.2) w3.org
- WCAG 2.2: Understanding Animation from Interactions (2.3.3) w3.org
- MDN: Window.matchMedia() developer.mozilla.org