Reduced motion: reduce, don't remove meaning
How to implement prefers-reduced-motion across CSS, JavaScript libraries, WebGL and native apps: a mapping from each motion type to its reduced equivalent, keeping feedback and state changes intact.
Overview
What: A consistent policy for users whose OS says "reduce motion" (macOS/iOS Reduce Motion, Windows Animation Effects off, Android Remove animations, GNOME/KDE equivalents), exposed to the web as prefers-reduced-motion: reduce.
Why: Vestibular disorders can make parallax, zooms, and large movements cause dizziness, nausea or migraine. WCAG 2.3.3 (AAA) asks that interaction-triggered motion can be disabled unless essential. But deleting every transition also deletes information — a panel appearing with no cue, a toggle with no feedback. The right move is substitution: keep meaning, drop movement.
Use when: always — every template and component must ship a reduced-motion path.
Do not treat animation: none !important on * as a complete solution; it can break components that rely on animationend events and removes useful feedback.
Implementation
- Mapping table (apply per effect):
| Full motion | Reduced equivalent |
|---|---|
| Slide/rise-in reveal | Opacity fade ≤150 ms, or instant |
| Parallax / scroll-scrubbed transforms | Static layout, final positions |
| Scale/zoom page or modal transition | Cross-fade ≤150 ms |
| Pinned / horizontal scroll story | Normal vertical stack of steps |
| Autoplay background video / WebGL loop | Poster image or single rendered frame, play button offered |
| Marquee | Static wrapped list |
| Smooth-scroll library | Native scroll |
| Button press scale | Colour/opacity change only |
| Spinner | Keep (it conveys status) but prefer slower rotation or a progress bar |
- CSS: author motion inside the "allowed" query, so the default is safe:
@media (prefers-reduced-motion: no-preference) {
.card { transition: transform var(--motion-dur-base) var(--motion-ease-enter); }
}
Plus token overrides (motion-design-tokens).
- JavaScript: read and subscribe:
const mq = matchMedia('(prefers-reduced-motion: reduce)');
let reduce = mq.matches; mq.addEventListener('change', e => { reduce = e.matches; restartOrStop(); });
- GSAP: wrap timelines in
gsap.matchMedia()with areduceMotioncondition; animations auto-revert when the query flips. - Motion (React): wrap the app in
<MotionConfig reducedMotion="user">— transform and layout animations are disabled while opacity/colour still animate; useuseReducedMotion()for bespoke swaps (e.g. video → image). - WebGL/canvas: render one frame and stop the loop; don't mount heavy scenes at all if the effect is purely decorative.
- React Native Reanimated: animations default to
ReduceMotion.System(timing/spring jump to end values); useuseReducedMotion()for alternative layouts; add.reduceMotion(ReduceMotion.System)explicitly on custom layout animations. - Optional in-page toggle ("Reduce motion") stored in
localStorage, settingdata-motion="reduced"on<html>, for users who don't know the OS setting — honour both. - Gate motion ON with
no-preference, not only OFF withreduce. Browsers that do not understand the query then behave like opted-out users — both get the safe static default:
@media (prefers-reduced-motion: no-preference) { .layer { animation: drift 20s linear infinite; } }
@media (prefers-reduced-motion: reduce) { .layer { animation: none; } }
This also lets animation-heavy CSS live in a stylesheet that opted-out users never download: <link rel="stylesheet" href="animations.css" media="(prefers-reduced-motion: no-preference)">.
- Cascade order. The media feature adds no specificity: a
reduceblock placed before the default rule silently loses. Putreduceoverrides after the rules they modify (the same bug bites@starting-styleandallow-discrete). - JS is not live; CSS is. Listen for changes and cancel in-flight work:
const mq = matchMedia('(prefers-reduced-motion: reduce)'); mq.addEventListener('change', () => { if (mq.matches) document.getAnimations().forEach(a => a.cancel()); }). The parentheses in the query string are mandatory — without themmatchMedianever matches. - Swap media, not just motion:
<picture>with<source media="(prefers-reduced-motion: no-preference)" srcset="hero.avif">and a static<img>fallback replaces an animated image for opted-out users. - Motion for React:
<MotionConfig reducedMotion="user">drops transform/layout motion app-wide while keeping opacity/colour;useReducedMotion()for bespoke branches (dropx, keep opacity;autoplay={!reduce}; pass0instead of a live parallax value)."always"/"never"let a site-level toggle override the OS setting (motion-react-motion-library-essentials). - For scroll and scene work the parallel is a design, not a toggle — author static-first (
motion-anti-pattern-retrofitted-reduced-motion). - No support excuse remains.
prefers-reduced-motionhas been Baseline Widely available since January 2020 and is the W3C's named sufficient technique (C39) for WCAG 2.3.3; for some users honouring it is a medical necessity, not a preference. Ship the query on every project regardless of framework. - Native platforms — the same substitution logic.
- iOS / SwiftUI: read
@Environment(\.accessibilityReduceMotion)(UIKit:UIAccessibility.isReduceMotionEnabled). Apple's own substitutions when it is on: tighten springs to remove bounce, track gestures directly instead of playing canned motion, drop z-axis/depth animations, replace x/y/z moves with fades, and never animate into or out of a blur. A signature animation with no reduced path is unfinished (app-ios-motion-haptics-swiftui). - Android: honour the system "Remove animations" setting —
ValueAnimator.areAnimatorsEnabled()is false when the animator duration scale (Settings.Global.ANIMATOR_DURATION_SCALE) is 0; Android 16 adds further motion-sickness options (announced at I/O 2025 — verify current setting names). Swap container transforms and shared-axis moves for fades while keeping predictive back working (app-android-motion-transitions-material). Critical information must never live only in an animation. - PWA / web app shell: same-document View Transitions and scroll-driven animations are motion too — author them inside
@media (prefers-reduced-motion: no-preference)so the reduced default is a plain cut (pwa-view-transitions-app-navigation,pwa-native-scroll-touch-and-haptics).
- iOS / SwiftUI: read
Verification
- Emulate reduced motion (Chrome DevTools > Rendering, or Firefox
ui.prefersReducedMotion=1) and walk every page: no parallax, no translation reveals, no autoplaying loops. - Toggles, menus, modals and form validation still show visible state change.
- Changing the OS/emulated setting while the page is open updates JS-driven animation without reload.
- No component depends on
animationend/transitionendfiring (test with durations of 0). - iOS Simulator/Android emulator with Reduce Motion / Remove animations on: native transitions degrade to fades or instant; no z-axis, blur or bounce remains; predictive back still works on Android.
- PWA shell:
::view-transition-*andanimation-timelinerules are inside ano-preferenceblock. - Animation rules are inside
no-preferenceblocks (or a media-scoped stylesheet);reduceoverrides appear after their base rules. - Every
matchMediaquery string is parenthesised; toggling the preference live cancels running WAAPI/CSS animations.
Sources:
- https://web.dev/articles/prefers-reduced-motion
- https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
- https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html
- https://motion.dev/docs/react-accessibility
- https://gsap.com/docs/v3/GSAP/gsap.matchMedia()/
- https://docs.swmansion.com/react-native-reanimated/docs/guides/accessibility/
- https://developer.apple.com/design/human-interface-guidelines/accessibility
- https://android-developers.googleblog.com/2025/05/android-design-google-io-25.html
Limitations
- Reduced motion is a preference signal, not a diagnosis; many sensitive users never enable it, so default designs should already be moderate.
prefers-reduced-motiondoesn't cover flashing (WCAG 2.3.1) or autoplay duration (2.2.2); those need separate handling.- Motion's
reducedMotion="user"keeps opacity animations; if a fade itself is long or looping, it still needs manual limits. - Reanimated's
useReducedMotionreads the setting at app start; changes mid-session may need a restart or a listener via AccessibilityInfo. - Blur is not excluded from WCAG 2.3.3's motion definition since the September 2025 errata; treat blur-in/out as motion to be disabled.
- Values verified 2026-09-26: all 2 numeric values in this item were checked (2 for consistency with house policy) and confirmed; no corrections were needed.