Respecting prefers-reduced-motion: Disabling Animations Deliberately
AI generated
x-data
Alpine
Alpine.js · Accessibility
Respecting prefers-reduced-motion: Disabling Animations Deliberately
Why an elegantly animated x-transition is not a nice-to-have for every user, but a genuine health risk for some

An unfolding mega menu, a softly fading-in modal, a parallax-style scroll effect: for most users, x-transition animations like these inside an Alpine component feel elegant and modern. For users with vestibular disorders, the very same motion can trigger dizziness, nausea, or headaches. The prefers-reduced-motion CSS media query gives exactly these users a way to record their preference at the operating system level. This article covers how an Alpine component reads that preference reliably, disables x-transition animations conditionally, and why this is not a cosmetic nicety but a WCAG requirement.

9 min read prefers-reduced-motion x-transition matchMedia

1. What prefers-reduced-motion is and who relies on it

prefers-reduced-motion is a system setting available on Windows, macOS, iOS, and Android that tells the browser a user wants motion effects reduced. It is used above all by people with vestibular disorders, whose balance organ reacts overly sensitively to visual motion cues, as well as people with migraines, where certain animation patterns can trigger an attack.

It matters that this setting is not a rare edge case, but according to various surveys affects a noticeable share of users, often considerably higher than many development teams assume, since affected users usually leave the setting permanently enabled at the operating system level, so it applies regardless of which specific site they visit.

2. Reading matchMedia inside an Alpine component

The system setting can be queried directly in the browser as a boolean via window.matchMedia('(prefers-reduced-motion: reduce)').matches, with no extra library required. In an Alpine component this value typically gets read once during init() and stored as a reactive property, so it is available throughout the component's markup via x-bind and x-show.

What matters is not querying matchMedia only once, but also registering a change listener on the MediaQueryList object, because a user can change the system setting at any time without reloading the page, for example while testing their own accessibility settings. If it is only read once, a later change to the setting during the current session goes unnoticed.

3. A global Alpine.store for the motion preference

Since the motion preference matters for practically every animated component on the page, a central Alpine.store that determines the preference once and exposes it consistently across the whole application pays off, instead of duplicating a separate matchMedia query inside every single component. Every component then reads the same value consistently through $store.motion.reduced.

This central store registers the change listener exactly once when the application initializes and updates its reactive property on every change, so every component bound to it automatically reacts to a later change in the system setting with no extra code required.


document.addEventListener('alpine:init', () => {
  Alpine.store('motion', {
    reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,

    init() {
      const query = window.matchMedia('(prefers-reduced-motion: reduce)');
      query.addEventListener('change', (event) => {
        this.reduced = event.matches;
      });
    },
  });
});

4. Disabling x-transition conditionally

With the global store in place, any x-transition directive can be adjusted to either drop out entirely when the reduced motion preference is active, or switch to a very short, non-spatial alternative such as a plain opacity fade with no translation or scaling. Alpine allows the transition classes or durations to be controlled dynamically through a bound expression for exactly this purpose.

A solid pattern is coupling the transition duration to a computed property tied to $store.motion.reduced, so a duration of practically zero milliseconds gets used when motion is reduced, while the visual start and end state stay identical. The motion disappears entirely without the component's underlying visibility logic needing separate maintenance.


<div x-data="{ open: false }">
  <button @click="open = !open">Open menu</button>

  <div
    x-show="open"
    x-transition:enter="transition ease-out"
    :x-transition:enter.duration="$store.motion.reduced ? 1 : 200"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    x-transition:leave="transition ease-in"
    :x-transition:leave.duration="$store.motion.reduced ? 1 : 150"
    x-transition:leave-start="opacity-100"
    x-transition:leave-end="opacity-0"
  >
    Menu content
  </div>
</div>

5. Practical example: a mega menu without animation under reduced motion

A mega menu in a Hyvä header that normally appears with a smooth unfold plus a slight vertical shift switches to an instant fade in and out with no shift at all once the preference is active. The user loses no functionality whatsoever, the menu still opens and closes exactly as before, only the motion itself gets removed.

It matters that the same logic gets applied consistently across every animated element on the page, not just a single example widget, because a user with a vestibular disorder only genuinely benefits if the reduction takes effect consistently across mini cart, modal, tooltip, and slider, instead of getting forgotten in individual spots.

6. Using the CSS layer in parallel: @media (prefers-reduced-motion: reduce)

Alongside the JavaScript solution through the Alpine store, the same media query can also be used directly in CSS, to shorten transitions and keyframe animations across the board for any elements not controlled through an explicit Alpine directive, such as plain CSS hover effects or Tailwind utility classes with transition. Both layers complement each other and should not be treated as alternatives.

A sensible split of responsibilities is using the CSS media query as a global safety net for every transition not explicitly controlled by Alpine, while the Alpine store gets used deliberately for more complex, state-driven animations where a pure CSS solution is not enough because the animation is tied to an Alpine state change.


@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;
  }
}

7. Reacting live to a runtime change in the system setting

If a user changes the motion preference through the operating system settings while the page is already open, the application should react without requiring a reload. The change listener in the global store handles exactly this, so every subsequent interaction, such as the next time the mega menu opens, automatically respects the updated preference.

This behavior is straightforward to test manually by toggling the system setting during an active session, without reloading the page, and then triggering an animated component again. If the animation stays unchanged despite the setting change, that suggests the preference only got read once at load time instead of being observed through a listener.

WCAG success criterion 2.3.3, Animation from Interactions, requires at the AAA level that motion animation triggered by interaction can be disabled, unless the animation is essential to the function or the information conveyed. Even though this specific criterion is only formally mandatory at the AAA level, it is treated in practice as an accepted best-practice standard that increasingly gets demanded by accessibility audits at the AA level too.

Beyond the formal conformance angle, this is about a genuine health impact: for people with vestibular disorders, an ill-considered animation can cause dizziness, nausea, or in severe cases days of discomfort, comparable to the triggers involved in motion sickness. Respecting prefers-reduced-motion is therefore less a matter of pure formality and more a direct matter of user health.

9. Common mistake: checking the preference only at load instead of observing it reactively

A frequent mistake is querying matchMedia correctly on the page's initial load but never registering a change listener, which means a system setting changed during the session gets ignored. For a user who deliberately turns the setting on because an animation just became uncomfortable, that means the page keeps animating despite the correctly set preference until a full reload happens.

A second, subtler mistake is respecting the preference correctly for x-transition animations while overlooking CSS-based keyframe animations or scroll effects that run outside Alpine's directives. Since both mechanisms work independently of each other, the reduction has to be implemented consistently on both layers at the same time, not just on one of them.

Approach Layer Reacts to runtime change Typical use
Alpine.store with matchMedia JavaScript Yes, via change listener State-driven x-transition animations
@media (prefers-reduced-motion) CSS Yes, natively through the browser CSS hover effects, Tailwind transitions
:x-transition.duration binding Alpine directive Yes, as long as bound to the store Fine-tuning individual transition durations
Static check only at load JavaScript without a listener No, stale after a change Anti-pattern to avoid

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Reduced Motion With Alpine: Key Takeaways

System setting

prefers-reduced-motion is available on every major operating system and affects more users than often assumed.

Central store

A global Alpine.store with a change listener avoids duplicate matchMedia queries in every component.

Two layers

CSS media query as a global safety net, Alpine store for state-driven animations.

Health relevance

For users with vestibular disorders, the reduction is not cosmetic, it is a genuine health necessity.

11. FAQ: Reduced Motion With Alpine: Key Takeaways

1What is prefers-reduced-motion and where is the setting configured?
It is a system setting on Windows, macOS, iOS, and Android that tells the browser a user wants motion effects reduced. It gets set at the operating system level and then applies regardless of which specific website is visited.
2Who benefits most from reduced motion?
Above all people with vestibular disorders, whose balance organ reacts overly sensitively to visual motion cues, as well as people with migraines, where certain animation patterns can trigger an attack.
3How does an Alpine component read the motion preference?
Through window.matchMedia('(prefers-reduced-motion: reduce)').matches as a boolean, typically read once during init() and additionally observed via a change listener so later changes during the session get detected.
4Why does a global Alpine.store for the motion preference pay off?
Since the preference matters for practically every animated component, a central store avoids duplicate matchMedia queries and exposes the value consistently across the whole application through $store.motion.reduced.
5How does an x-transition animation get disabled under reduced motion?
The transition duration gets tied dynamically to $store.motion.reduced, so a duration of practically zero milliseconds gets used when the preference is active, while the transition's start and end state stay identical.
6Why isn't the CSS media query alone always enough?
The plain CSS media query only applies to transitions and animations actually controlled through CSS. State-driven animations bound to Alpine variables additionally need the JavaScript layer through the store.
7How do you test whether an Alpine component reacts to a runtime change in the preference?
Toggle the system setting during an active session without reloading the page, then trigger an animated component again. If the animation stays unchanged, the change listener is likely missing.
8Which WCAG requirement sits behind respecting prefers-reduced-motion?
Success criterion 2.3.3, Animation from Interactions, requires at the AAA level that interaction-triggered motion animation can be disabled unless essential to function or information. In practice it counts as an accepted best-practice standard.
9What health impact can an ignored motion preference cause?
For people with vestibular disorders, an ill-considered animation can cause dizziness, nausea, or in severe cases days of discomfort, comparable to the triggers involved in motion sickness.
10What is the most common mistake when respecting prefers-reduced-motion?
The preference only gets checked once at page load, but no change listener gets registered, so a system setting changed during the session gets ignored and the page keeps animating despite the correctly set preference.