Respecting prefers-reduced-motion
How to read the prefers-reduced-motion setting, which animations to cut or replace, and what WCAG 2.3.3 and 2.2.2 ask of moving content.
6 min read
Large movement on screen makes some people dizzy, nauseated or unable to concentrate. A parallax hero, a page that slides in sideways, a price ticker crawling across the top of an exchange: most visitors barely notice them, while people with vestibular disorders and some with attention or migraine conditions can be knocked out of the page entirely. Operating systems let those people turn motion down, and browsers pass the choice to your CSS and JavaScript as prefers-reduced-motion.
Honoring it is usually a few lines of code. The harder part is deciding what to change, and knowing where the media query is not enough on its own.
Where the setting comes from
The media query has two values, no-preference and reduce. People set it once, in their system settings:
- macOS: System Settings > Accessibility > Display > Reduce motion
- iOS and iPadOS: Settings > Accessibility > Motion > Reduce Motion
- Windows 11: Settings > Accessibility > Visual effects, then turn off Animation effects
- Android: an option called Remove animations, usually under Accessibility, though the location varies by manufacturer and version
Menu names shift between OS releases, so if a path doesn't match, search the settings app for "motion" or "animation". You don't need to change your own system to test. In Chrome and Edge, open DevTools, then the Rendering panel, and use "Emulate CSS media feature prefers-reduced-motion". In Firefox you can add a number preference named ui.prefersReducedMotion in about:config and set it to 1.
Decide what to reduce
"Reduce" does not mean "remove every animation". The motion that causes trouble covers a large area, moves things across the screen, is tied to scrolling, or runs without the user asking for it. Small changes in color or opacity are rarely a problem. A practical sorting for a typical dApp or exchange:
| Motion | When reduce is set |
|---|---|
| Parallax layers and scroll-linked effects | Remove; let the page scroll normally |
| Pages or panels that slide, zoom or flip in | Replace with a short fade or an instant change |
| Scrolling price tickers and marquees | Show a static row the user can scroll |
| Smooth scrolling to anchors | Jump straight to the target |
| Confetti or particle bursts after a swap or mint | Skip them; the text confirmation stays |
| Balances that count up or digits that roll | Show the final number |
| Autoplaying background video | Show a still frame with a play button |
| Hover color changes, opacity fades | Usually fine to keep |
| A small spinner on a pending transaction | Usually fine; some teams swap it for a static "Pending" label |
Whatever you remove, keep the information it carried. If a sliding drawer told people where the panel came from, a heading inside the panel can do the same job. If confetti was the only sign that a swap went through, the swap needed a proper status message anyway, which announcing transaction status covers.
CSS: make motion opt-in
The quickest fix is a global reset that shortens every animation and transition:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
The durations are 0.01ms rather than 0 on purpose. A transition with no duration never runs, so it never fires transitionend, and code that waits for that event can hang. Also check staggered entrances that rely on animation-delay: with a fill mode of backwards or both, elements sit at their first keyframe (often invisible) until the delay ends, and the reset doesn't shorten delays.
A reset is a safety net, but it treats a fade the same as a full-screen slide. The cleaner pattern is to write the calm version as the default and add movement only inside no-preference:
.toast {
opacity: 0;
transition: opacity 150ms ease-out;
}
.toast[data-open] {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
.toast {
transform: translateY(12px);
transition: opacity 150ms ease-out, transform 250ms ease-out;
}
.toast[data-open] {
transform: none;
}
}
With reduce set, the toast still fades in, because a change in opacity isn't movement, but it no longer slides. Browsers that don't understand the query at all get the calm version.
JavaScript-driven motion
Charts that animate on load, scrollIntoView calls, canvas effects and animation libraries all bypass your CSS. Read the same preference with matchMedia, and listen for changes, because people do toggle the setting with your page open. In React, useSyncExternalStore fits this well:
import { useSyncExternalStore } from 'react'
const QUERY = '(prefers-reduced-motion: reduce)'
function subscribe(onChange: () => void) {
const mql = window.matchMedia(QUERY)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}
export function useReducedMotion() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => true, // server render: assume reduced until the browser says otherwise
)
}
Then branch where the motion happens:
const reduced = useReducedMotion()
function revealQuote() {
quoteRef.current?.scrollIntoView({
behavior: reduced ? 'auto' : 'smooth',
block: 'nearest',
})
if (!reduced) playSuccessBurst()
}
playSuccessBurst stands in for any decorative effect. It becomes optional; the scroll still happens.
What WCAG asks
Two success criteria deal with motion, and they work differently.
2.3.3 Animation from Interactions (AAA) says motion animation triggered by an interaction can be disabled, unless the animation is essential. It covers parallax on scroll, sliding page transitions and similar effects. Changes in color, blur or opacity are not motion animation in this sense. It is a AAA criterion, so it isn't required for AA conformance, but respecting prefers-reduced-motion is one of the ways to meet it, and it costs little. For what the levels mean in practice, see WCAG levels A, AA and AAA explained.
2.2.2 Pause, Stop, Hide (A) is a level A requirement, and the media query does not satisfy it by itself. It applies to moving, blinking or scrolling content that starts automatically, lasts more than five seconds and sits alongside other content: there must be a way to pause, stop or hide it, unless the movement is essential. The same criterion covers auto-updating content, which needs a way to pause, stop or hide it, or to control how often it updates. A scrolling price ticker hits both parts. People bothered by it may not know the OS setting exists, so they need a control on the page:
<div className="ticker" data-paused={paused}>
<button type="button" aria-pressed={paused} onClick={() => setPaused((p) => !p)}>
Pause ticker
</button>
<ul className="ticker-track">{/* prices */}</ul>
</div>
.ticker-track {
animation: ticker 40s linear infinite;
}
.ticker[data-paused='true'] .ticker-track,
.ticker:hover .ticker-track,
.ticker:focus-within .ticker-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.ticker-track {
animation: none;
}
.ticker {
overflow-x: auto;
}
}
Pausing on hover and focus helps, but it doesn't replace the button: touch users can't hover, and the ticker should stay stopped once someone has asked for that. Prices that update in place every second fall under the auto-updating part of 2.2.2 even when nothing scrolls. Rows that flash green and red on each tick add visual noise on top. Making token amounts and prices accessible covers how to handle those updates.
A quick pass before release
- Turn on reduced motion in DevTools and walk through connect, swap and send.
- Parallax, sliding pages and scroll-linked effects are gone; fades remain where they help.
- Smooth scrolling only exists inside
no-preference. - Any ticker or carousel has a visible pause control and is static when reduce is set.
- JavaScript animations check the preference and react when it changes.
- Every success or failure that used to be shown through animation is also shown in text.