Free guideEditorial review in progress; your agent sees whether each guide's current version is reviewed. What “reviewed” means

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.

Discipline
Motion
Type
Principle
Platforms
Website, Web app, iOS, Android, Cross-platform
Version
1.2.1 · 2026-09-26

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

  1. Mapping table (apply per effect):
Full motionReduced equivalent
Slide/rise-in revealOpacity fade ≤150 ms, or instant
Parallax / scroll-scrubbed transformsStatic layout, final positions
Scale/zoom page or modal transitionCross-fade ≤150 ms
Pinned / horizontal scroll storyNormal vertical stack of steps
Autoplay background video / WebGL loopPoster image or single rendered frame, play button offered
MarqueeStatic wrapped list
Smooth-scroll libraryNative scroll
Button press scaleColour/opacity change only
SpinnerKeep (it conveys status) but prefer slower rotation or a progress bar
  1. 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).

  1. JavaScript: read and subscribe:
const mq = matchMedia('(prefers-reduced-motion: reduce)');
let reduce = mq.matches; mq.addEventListener('change', e => { reduce = e.matches; restartOrStop(); });
  1. GSAP: wrap timelines in gsap.matchMedia() with a reduceMotion condition; animations auto-revert when the query flips.
  2. Motion (React): wrap the app in <MotionConfig reducedMotion="user"> — transform and layout animations are disabled while opacity/colour still animate; use useReducedMotion() for bespoke swaps (e.g. video → image).
  3. WebGL/canvas: render one frame and stop the loop; don't mount heavy scenes at all if the effect is purely decorative.
  4. React Native Reanimated: animations default to ReduceMotion.System (timing/spring jump to end values); use useReducedMotion() for alternative layouts; add .reduceMotion(ReduceMotion.System) explicitly on custom layout animations.
  5. Optional in-page toggle ("Reduce motion") stored in localStorage, setting data-motion="reduced" on <html>, for users who don't know the OS setting — honour both.
  6. Gate motion ON with no-preference, not only OFF with reduce. 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)">.

  1. Cascade order. The media feature adds no specificity: a reduce block placed before the default rule silently loses. Put reduce overrides after the rules they modify (the same bug bites @starting-style and allow-discrete).
  2. 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 them matchMedia never matches.
  3. 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.
  4. Motion for React: <MotionConfig reducedMotion="user"> drops transform/layout motion app-wide while keeping opacity/colour; useReducedMotion() for bespoke branches (drop x, keep opacity; autoplay={!reduce}; pass 0 instead of a live parallax value). "always"/"never" let a site-level toggle override the OS setting (motion-react-motion-library-essentials).
  5. For scroll and scene work the parallel is a design, not a toggle — author static-first (motion-anti-pattern-retrofitted-reduced-motion).
  6. No support excuse remains. prefers-reduced-motion has 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.
  7. 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).

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/transitionend firing (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-* and animation-timeline rules are inside a no-preference block.
  • Animation rules are inside no-preference blocks (or a media-scoped stylesheet); reduce overrides appear after their base rules.
  • Every matchMedia query string is parenthesised; toggling the preference live cancels running WAAPI/CSS animations.

Sources:

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-motion doesn'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 useReducedMotion reads 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.