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.
Table of Contents
- 1. What prefers-reduced-motion is and who relies on it
- 2. Reading matchMedia inside an Alpine component
- 3. A global Alpine.store for the motion preference
- 4. Disabling x-transition conditionally
- 5. Practical example: a mega menu without animation under reduced motion
- 6. Using the CSS layer in parallel: @media (prefers-reduced-motion: reduce)
- 7. Reacting live to a runtime change in the system setting
- 8. Legal and ethical relevance: WCAG 2.3.3 and health impact
- 9. Common mistake: checking the preference only at load instead of observing it reactively
- 10. Summary
- 11. FAQ
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.
8. Legal and ethical relevance: WCAG 2.3.3 and health impact
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.