prefers-reduced-motion: Making Animations Accessible
AI generated
A11Y
WCAG
Accessibility · prefers-reduced-motion · CSS · JavaScript
prefers-reduced-motion: Making Animations Accessible
When motion in the interface causes harm instead of delight

Parallax scrolling, large zoom transitions, and autoplaying background videos feel modern to many users, but they can trigger genuine nausea, dizziness, and migraines in people with vestibular disorders. The prefers-reduced-motion media query reliably detects this preference in both CSS and JavaScript, enabling a reduced, still functional animation instead of an abrupt interface with no feedback at all.

11 min. read prefers-reduced-motion · WCAG 2.3.3 · Vestibular Disorders CSS · JavaScript · Alpine.js · GSAP

1. Why motion effects can make some users ill

Large, fast, or seemingly uncontrolled motion in an interface is not an aesthetic detail for people with vestibular disorders, it is a concrete trigger for real physical symptoms. The vestibular system in the inner ear governs the sense of balance, and when visual motion cues do not match actual body movement, a conflict arises that the brain interprets as motion sickness. Parallax scrolling, where foreground and background move at different speeds, is one of the strongest triggers of all, because it simulates exactly that contradiction. According to the US National Institute on Deafness and Other Communication Disorders, a significant share of the adult population experiences some form of vestibular impairment during their lifetime.

Symptoms range from mild dizziness and loss of concentration to nausea, headaches, and full-blown migraine attacks that make a website immediately unusable. Especially risky are large-scale zoom and rotation animations, autoplaying background videos, and scroll-driven reveal effects where multiple elements move simultaneously in different directions. WCAG 2.3.3 (Animation from Interactions) therefore requires at level AAA that motion triggered by interaction be disableable unless it is essential to the function, and WCAG 2.2.2 (Pause, Stop, Hide) already requires control over automatically starting, moving content at level A.

2. The prefers-reduced-motion media query: how it works and support

Since 2019, every major operating system and browser has supported the CSS media query prefers-reduced-motion, with the two possible values no-preference and reduce. Users do not set this preference on the website itself but centrally in their operating system's accessibility settings: macOS under "System Settings, Accessibility, Display, Reduce motion", Windows under "Settings, Accessibility, Visual effects, Animation effects", iOS and Android in comparable locations. This setting applies system-wide across every app and website at once, which makes it far more reliable than a single, site-specific opt-out toggle that many users would never even find.

What matters for implementation: reduce does not mean "no motion whatsoever", it means "as little motion as necessary for the function". The specification deliberately frames this as a preference, not a prohibition, because some motion, such as a subtle loading indicator, still remains meaningful. In CSS, the query is conventionally used as an "opt-in in reduce" approach, meaning animations are defined normally and then specifically dialed back for reduce, rather than the reverse of enriching for no-preference. That ensures a website stays safe even if a browser evaluates the query differently in the future, or does not support it at all.

3. CSS: deliberately dialing back animations and transitions

The most robust CSS approach combines two techniques: first, motion duration and distance are defined centrally as CSS custom properties, so a single media query block can affect the entire website instead of overriding every animation individually. Second, problematic properties such as large transform: translate or scale values, parallax background-attachment, and automatically starting animation declarations are neutralized specifically, without removing meaningful state transitions altogether. scroll-behavior: smooth also counts among the motion effects that should be reset to auto once the preference is active, because a sudden jump is more comfortable for many users than a forced, smooth scroll across an entire page.

A commonly recommended but overly blunt solution is a global selector like * { animation: none !important; } inside the media query. That works technically, but it also removes essential motion such as focus rings, loading indicators, or status changes that are themselves necessary for usability. The more targeted approach instead reduces animation-duration and transition-duration to a minimal but non-zero value, leaving element-specific, non-vestibular-risky transitions such as color changes or opacity untouched.


/* Baseline: define motion duration and distance centrally as custom properties */
:root {
  --motion-duration: 0.6s;
  --motion-distance: 40px;
  --motion-easing: cubic-bezier(0.16, 1, 0.3, 1);
}

.hero-reveal {
  transform: translateY(var(--motion-distance));
  transition: transform var(--motion-duration) var(--motion-easing),
              opacity var(--motion-duration) var(--motion-easing);
}

.parallax-layer {
  background-attachment: fixed; /* strong vestibular trigger */
  transform: translateY(calc(var(--scroll-offset, 0) * 0.4));
}

/* Reduced instead of no motion: short duration, no translation, no parallax */
@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-duration: 0.01ms;
    --motion-distance: 0px;
  }

  .parallax-layer {
    background-attachment: scroll; /* remove the parallax trigger entirely */
    transform: none;
  }

  html {
    scroll-behavior: auto; /* no forced smooth scrolling */
  }

  /* Deliberately exclude essential motion: focus ring stays visible */
  .focus-ring-pulse {
    animation-duration: 1.2s;
  }
}

4. JavaScript: controlling dynamic animation with matchMedia

CSS media queries only cover purely declarative animation. As soon as JavaScript calculates positions, for example scroll-driven parallax through requestAnimationFrame, IntersectionObserver-based reveal effects, or canvas animations, the preference must be explicitly queried via window.matchMedia('(prefers-reduced-motion: reduce)'). What matters here is not just checking the value once when the page loads, but also listening for the change event on the MediaQueryList object. Users can change the system setting at any time while the page is already open, and a script that only checks on initial load ignores that change until the next full reload.

For running requestAnimationFrame loops, consistent implementation means either stopping the loop entirely once the preference is active, or switching it to a noticeably reduced motion amplitude, rather than letting it continue unchanged. For canvas or WebGL-based background animations, which CSS cannot reach at all, this is the only reliable place where the user preference can take effect.


// Encapsulate reduced-motion preference centrally and react live to changes
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let prefersReducedMotion = motionQuery.matches;

motionQuery.addEventListener('change', (event) => {
  prefersReducedMotion = event.matches;
  updateParallaxState();
});

let rafId = null;

function startParallaxLoop() {
  if (prefersReducedMotion) {
    // Do not start a continuous motion loop
    return;
  }

  function frame() {
    const offset = window.scrollY * 0.4;
    document.querySelectorAll('.parallax-layer').forEach((el) => {
      el.style.transform = `translateY(${offset}px)`;
    });
    rafId = requestAnimationFrame(frame);
  }
  rafId = requestAnimationFrame(frame);
}

function updateParallaxState() {
  if (prefersReducedMotion && rafId) {
    cancelAnimationFrame(rafId);
    rafId = null;
    document.querySelectorAll('.parallax-layer').forEach((el) => {
      el.style.transform = 'none';
    });
  } else if (!prefersReducedMotion && !rafId) {
    startParallaxLoop();
  }
}

updateParallaxState();

5. Reduced instead of removed: building sensible fallbacks

The most important conceptual mistake is equating "reduced motion" with "no motion at all". An interface that snaps every transition abruptly with no feedback whatsoever once the preference is active loses orientation cues that are valuable even for users with vestibular disorders, such as a gentle fade-in of new content that signals something just loaded. The better strategy distinguishes between vestibular-risky motion, which must be removed, and functionally important but harmless motion, which is kept in reduced form: short opacity changes instead of large translations, a simple color change instead of simultaneous rotation and scaling, a brief, small pulse instead of a bouncing effect.

In practice this can be governed by a central motion token system that defines two variants for every type of motion, one for the default preference and one for reduce. That keeps the codebase consistent, and design decisions about acceptable reduced motion get made in one place instead of scattered across individual components.


{
  "motionTokens": {
    "heroReveal": {
      "default": { "duration": "600ms", "distance": "40px", "property": "transform, opacity" },
      "reduce":   { "duration": "150ms", "distance": "0px",  "property": "opacity" }
    },
    "cardHover": {
      "default": { "duration": "250ms", "transform": "scale(1.04)" },
      "reduce":   { "duration": "150ms", "transform": "none", "property": "box-shadow" }
    },
    "pageTransition": {
      "default": { "duration": "500ms", "effect": "slide-fade" },
      "reduce":   { "duration": "120ms", "effect": "fade-only" }
    },
    "loadingIndicator": {
      "default": { "duration": "1200ms", "effect": "spin" },
      "reduce":   { "duration": "1200ms", "effect": "pulse-opacity" }
    }
  }
}

6. Framework integration: Alpine.js, GSAP, and the Web Animations API

In Hyvä themes, the preference can be cleanly encapsulated as a global Alpine.js store, so every component in the template can read it without querying the media query itself multiple times. The store holds the current value reactively and updates automatically through the change listener, so x-bind and x-transition directives can react directly to the preference. For more elaborate animation libraries such as GSAP, the gsap.matchMedia() API offers a native way to bind entire animation contexts to media query states, including automatic cleanup on state change.

The native Web Animations API (element.animate()) should also check the preference before every call and, when reduce is active, either use reduced keyframes or work directly with getAnimations().forEach(a => a.finish()) to move an animation immediately into its end state rather than leaving it stuck in its suppressed starting state.


<!-- Hyvä phtml: global Alpine store for the reduced-motion preference -->
<script type="text/x-magento-init">
{
    "*": {
        "Magento_PageCache/js/page-cache": {}
    }
}
</script>

<div x-data
     x-init="
        Alpine.store('motion', { reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches });
        window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
            Alpine.store('motion').reduced = e.matches;
        });
     ">
</div>

<!-- Component reacts directly to the global store -->
<div
    x-data="{ open: false }"
    x-show="open"
    x-transition:enter.duration.500ms="!$store.motion.reduced"
    x-transition:enter.duration.100ms="$store.motion.reduced"
    x-transition:enter="transition ease-out"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
>
  Confirmation: item added to the cart.
</div>

7. Testing: OS settings, DevTools, and automated checks

The most reliable test remains toggling the actual operating system setting, because that ensures both CSS media queries and JavaScript checks respond correctly. For fast iteration during development, Chrome DevTools offers "Emulate CSS media feature prefers-reduced-motion" under the "Rendering" tab, which simulates the state without switching the system setting. Firefox offers a comparable option through its responsive design tools.

For automated regression tests, Playwright supports setting the preference directly via page.emulateMedia({ reducedMotion: 'reduce' }), which makes it possible to verify that a parallax layer actually stays still or that a loading indicator correctly switches to its reduced variant. axe-core does not automatically detect missing prefers-reduced-motion handling, because it is a behavioral issue rather than a purely static markup problem. That is why a dedicated, manual or script-driven functional test for every motion-heavy component remains indispensable.


// Playwright test: parallax layer stays static with prefers-reduced-motion: reduce
const { test, expect } = require('@playwright/test');

test('parallax layer stays static with prefers-reduced-motion: reduce', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('https://shop.example.com/');

  const transformBefore = await page.locator('.parallax-layer').evaluate(
    (el) => getComputedStyle(el).transform
  );

  await page.mouse.wheel(0, 800);
  await page.waitForTimeout(300);

  const transformAfter = await page.locator('.parallax-layer').evaluate(
    (el) => getComputedStyle(el).transform
  );

  expect(transformAfter).toBe(transformBefore);
});

8. Common mistakes in implementation

The most common mistake is checking the preference only once when the page loads, without reacting to the change event. Since users can flip the setting in their operating system at any time, a website needs to respond within the same session, otherwise an already open page stays stuck with the originally detected preference. A second widespread mistake concerns video and GIF content: a <video autoplay> element used as a moving background does not automatically respond to the media query and must be explicitly paused via JavaScript or replaced with a static poster image, while animated GIFs cannot be paused client-side at all and should be replaced with pausable video or CSS alternatives.

A third mistake is applying animation: none !important too bluntly to every element, which also removes essential, harmless motion such as loading indicators or focus rings and strips users of system-level feedback. A fourth, often overlooked mistake concerns scroll-driven third-party JavaScript libraries, for example for sliders or carousels, that bring their own unrespected motion logic. Before adopting such a library, it is worth checking whether it offers its own reduced-motion option, or whether the motion needs to be manually suppressed through a wrapper.

9. Motion effects compared: risky versus accessible

Not every motion effect is equally risky, and not every accessible alternative means giving up motion entirely. The table below shows, for the most common effect types in Magento and Hyvä stores, which behavior is problematic and what the reduced, still functional alternative looks like.

Effect Risky for vestibular disorders Accessible alternative Benefit
Parallax background Foreground/background move at different speeds Static image, no background-attachment: fixed No vestibular motion conflict
Hero zoom transition Large-scale scaling on load Short opacity fade, no scaling Content stays instantly recognizable
Autoplay background video Continuous motion with no control Static poster image, video paused No sustained motion stimulus
Scroll reveal animation Several elements move at once Instantly visible, opacity fade only No directional change while scrolling
Loading indicator/spinner Low risk, but functionally important Subtle opacity pulse instead of rotation Feedback preserved, no rotation

The underlying pattern repeats across every effect type: motion that shifts two or more visual layers against each other, or scales a large area quickly, is the main trigger. A short, small opacity change is almost never a problem and still delivers the feedback needed to signal that the interface state has changed.

Mironsoft

Accessible animation, prefers-reduced-motion, and Hyvä accessibility audits

Is your interface safe for vestibularly sensitive users too?

We audit parallax effects, loading animations, and scroll interactions in your Magento or Hyvä store for risky motion and implement prefers-reduced-motion consistently across CSS and JavaScript, with reduced rather than missing fallbacks.

Motion audit

Systematic review of every parallax, scroll, and transition animation

Reduced motion implementation

CSS custom properties, matchMedia, and an Alpine.js store for consistent fallbacks

CI integration

Playwright tests for motion regressions in the deployment pipeline

10. Summary

Parallax scrolling, large zoom transitions, and autoplaying background videos are not a cosmetic issue for users with vestibular disorders, they can trigger nausea, dizziness, and migraines. prefers-reduced-motion gives this group of users a system-wide way to communicate their preference, and websites must respect it both in declarative CSS through the media query and in JavaScript-driven animation through matchMedia, including reacting to the change event at runtime. What matters most is offering reduced rather than missing motion: remove vestibular-risky effects such as parallax and large-scale scaling, but keep functionally important, harmless motion such as subtle opacity changes.

In Hyvä themes, the preference can be cleanly encapsulated through a global Alpine.js store without pulling in additional libraries. Anyone who consistently drives the media query through CSS custom properties and a central motion token system, and who guards the implementation against regressions with Playwright tests, turns motion in the interface into a design tool rather than an access barrier for part of the user base.

prefers-reduced-motion: making animations accessible, the essentials at a glance

Vestibular disorders

Parallax, large zoom effects, and autoplay video can trigger genuine nausea and dizziness, not a purely cosmetic issue.

CSS and JavaScript

@media (prefers-reduced-motion: reduce) for declarative animation, matchMedia with a change listener for JS-driven motion.

Reduced instead of removed

Remove risky motion, keep functionally important, harmless motion such as opacity changes in reduced form.

Testing

Real OS settings, DevTools emulation, and Playwright tests with emulateMedia({ reducedMotion: 'reduce' }).

11. FAQ: prefers-reduced-motion and Accessible Animations

1What is prefers-reduced-motion?
A CSS media query that checks the operating system preference for reduced motion, evaluable directly in CSS and in JavaScript via matchMedia, with the values no-preference and reduce.
2Why can animations make some users ill?
For people with vestibular disorders, motion that does not match body movement creates a sensory conflict. Parallax scrolling and large zoom effects are the strongest triggers.
3How do I enable reduced motion for testing?
macOS: Accessibility, Display, Reduce motion. Windows: Accessibility, Visual effects. Chrome DevTools: Rendering tab, Emulate CSS media feature prefers-reduced-motion.
4Is it better to remove animations completely instead of reducing them?
No. Completely removed motion strips away important feedback. Better: remove risky effects, keep short, harmless transitions like opacity changes.
5How do I handle CSS keyframe animations specifically?
Reduce animation-duration inside the media query to a very short value or replace keyframes with a harmless alternative like an opacity change, instead of applying animation: none blanket-wide.
6How do I react to changes in the setting at runtime in JavaScript?
Create a MediaQueryList via matchMedia and react through addEventListener('change', handler), since the setting can change at any point during the session.
7What about video autoplay and animated GIFs?
Pause autoplay videos via JavaScript or replace with a poster image, since the media query does not automatically control the video element. Replace animated GIFs with pausable alternatives.
8How do you test this automatically in CI?
With Playwright via page.emulateMedia({ reducedMotion: 'reduce' }), verify parallax layers stay still and loading indicators switch correctly. axe-core does not detect this behavior automatically.
9Does prefers-reduced-motion apply to scroll-behavior: smooth?
Yes. Smooth scrolling should reset to scroll-behavior: auto once the preference is active, since an abrupt jump is more comfortable for many affected users.
10How do animation libraries like GSAP support prefers-reduced-motion?
GSAP offers gsap.matchMedia() for binding animation contexts to media query states, including automatic cleanup. The Web Animations API should manually check the preference before every animate() call.