Building accessible, WCAG-compliant animations
Animations and transitions improve the user experience, for most users. For people with vestibular disorders, migraines, or epilepsy, those same animations can trigger serious health problems. The Tailwind CSS motion-reduce variant makes it easier than ever to build accessible animations and meet WCAG 2.2.
Table of Contents
- 1. Why motion-reduce is more than a nice-to-have
- 2. How prefers-reduced-motion works technically
- 3. The motion-reduce variant in Tailwind CSS
- 4. animate-none: switching off animations deliberately
- 5. Making transitions accessible
- 6. Complex animations and scroll-based effects
- 7. JavaScript animations and Alpine.js
- 8. motion-reduce vs. no animation at all: comparison
- 9. WCAG 2.2 criterion 2.3.3 in practice
- 10. Summary
- 11. FAQ
1. Why motion-reduce is more than a nice-to-have
Vestibular disorders affect millions of people worldwide. The vestibular system in the inner ear is responsible for our sense of balance and spatial orientation. When parallax scrolling, rotating elements, fast transitions, or pulsing animations stimulate visual perception, they can trigger dizziness, nausea, disorientation, and in extreme cases episodes that last for hours in affected users. This reaction is not oversensitivity, it is a neurological reality.
The CSS media feature prefers-reduced-motion and the Tailwind CSS motion-reduce variant derived from it give developers a direct tool for responding to the operating system setting "reduce motion". Users who enable this setting, in iOS via Accessibility, in macOS via System Settings, in Windows via Ease of Access, are explicitly signaling that they want less movement on screen. A website that ignores this signal violates WCAG 2.2 criterion 2.3.3 (Animation from Interactions) and actively excludes those users.
The Tailwind CSS motion-reduce variant makes it technically simple to respond to this signal. Instead of writing media queries by hand in custom CSS, the motion-reduce: prefix is applied directly in the markup to the relevant classes. That improves readability, reduces the chance of gaps, and makes accessible animation a natural part of the Tailwind workflow.
2. How prefers-reduced-motion works technically
The CSS media feature prefers-reduced-motion has two possible values: no-preference and reduce. If the user has enabled the "reduce motion" option in their operating system, the media feature returns reduce. Browsers read this system setting and expose it through the CSS media query. In plain CSS you would write @media (prefers-reduced-motion: reduce) { .element { animation: none; } }. Tailwind CSS abstracts this media query behind the motion-reduce variant.
The default value is no-preference, meaning the user has not stated an explicit preference. In that state, all animations run normally. It is important to understand that no-preference does not mean "the user wants animations", it only means they have not explicitly requested a reduction. The motion-reduce variant only reacts to reduce. It does not enable any additional animations for users who have no-preference set.
3. The motion-reduce variant in Tailwind CSS
In Tailwind CSS, the motion-reduce variant is placed as a prefix in front of any utility class. The pattern is always the same: first define the normal animation for all users, then use motion-reduce: to define the reduced version for users with the system setting active. This is semantically clear and reads directly in the markup: "this animation runs normally, except when the user wants less motion."
The motion-reduce variant works with all of Tailwind's animation classes (animate-spin, animate-ping, animate-pulse, animate-bounce, animate-none), with all transition classes (transition, duration-*, ease-*), and with transform utilities (translate-*, scale-*, rotate-*). So it can not only switch off animations, it can also slow down transitions or zero out transforms.
<!-- Loading spinner: spins normally, stationary for motion-reduce users -->
<div class="animate-spin motion-reduce:animate-none
w-8 h-8 border-4 border-sky-500 border-t-transparent rounded-full">
</div>
<!-- Pulsing notification badge -->
<span class="relative flex h-3 w-3">
<span class="animate-ping motion-reduce:animate-none
absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-sky-500"></span>
</span>
<!-- Hover card with transition, slowed down, not removed, for reduced motion -->
<div class="transition-transform duration-300 ease-out hover:-translate-y-1
motion-reduce:transition-none motion-reduce:hover:translate-y-0
bg-white rounded-xl p-6 shadow-md">
Card content
</div>
<!-- Bounce animation fully removed -->
<button class="animate-bounce motion-reduce:animate-none bg-sky-500 text-white px-4 py-2 rounded-lg">
Scroll down
</button>
4. animate-none: switching off animations deliberately
The combination motion-reduce:animate-none is the simplest and most common motion-reduce pattern in Tailwind CSS. It stops the animation entirely without hiding the element itself or changing the layout. A loading spinner stays visible, it simply stops spinning. A pulse badge remains recognizable, it just stops pulsing. This is the right approach for informational animations that carry no functional content.
For animations defined via CSS keyframes that live under a custom class or via @utility in Tailwind v4, motion-reduce:animate-none must also be set explicitly. The motion-reduce variant in Tailwind does not automatically react to every animation, it must be applied directly in the markup on each animated class. This is a deliberate trade-off: it makes the behavior transparent in the markup and avoids magic global behavior that would be hard to debug.
5. Making transitions accessible
Transitions are a subtler challenge than animations because they are often less obviously perceptible. A 300ms hover transition on a button is a pleasant piece of feedback for most users. For users with vestibular disorders, who react sensitively to any visual movement, even that short transition can be disruptive. The motion-reduce recommendation for transitions has two parts: transitions that only change color or opacity (no spatial movement) can often be kept as-is. Transitions that involve translate, scale, or rotate should be removed with motion-reduce:transition-none.
The WCAG guideline distinguishes between "essential" and "non-essential" animation. A transition that merely improves visual feedback is non-essential and should be removed on request. A transition that informs the user of a status change (for example, a progress bar that moves) is essential and, under motion-reduce, can be replaced with an alternative, non-moving mechanism, for example by setting the end value directly without a transition.
/* Custom keyframe animation, must explicitly support motion-reduce */
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@utility animate-slide-in {
animation: slide-in 0.4s ease-out forwards;
}
/* In Tailwind v4: companion utility for reduced motion */
@utility animate-fade-in {
animation: fade-in 0.3s ease-out forwards;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/*
Usage in HTML:
<div class="animate-slide-in motion-reduce:animate-fade-in">
Content fades in instead of sliding, no spatial movement
</div>
*/
/* For prefers-reduced-motion: a global baseline reset (optional) */
@media (prefers-reduced-motion: reduce) {
/* Keeps opacity transitions, removes transform-based movement */
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
6. Complex animations and scroll-based effects
Scroll-based animations are one of the most common sources of vestibular problems on the modern web. Parallax effects, where the background and foreground scroll at different speeds, can trigger severe dizziness in affected users within seconds. Tailwind's motion-reduce variant does not automatically solve this problem for JavaScript-driven scroll animations, here JavaScript itself has to react to the media feature.
In Hyvä Themes and Alpine.js components, it is worth querying window.matchMedia('(prefers-reduced-motion: reduce)') when initializing every scroll animation. If the media feature is active, the animation is either never started at all or reduced to a plain opacity change that produces no spatial movement. The motion-reduce principle from Tailwind, define the animation first, then explicitly reduce it, should be applied just as consistently in JavaScript.
7. JavaScript animations and Alpine.js
Tailwind CSS motion-reduce only applies to CSS-defined animations and transitions. Animations that are fully driven by JavaScript, for example via requestAnimationFrame, the Web Animations API, or GSAP, must respond to the user setting separately. In Alpine.js, the pattern for this is simple: inside x-init, the media feature is queried and the result stored in a data property. All animation logic then checks this property before applying any transformations.
For scroll-observer-based animations, a global boolean prefersReducedMotion queried once when the page loads is a good approach. Every component that animates on scroll events reads this boolean. That avoids duplicating the same media-feature check in ten different places. In addition, the media feature's change event should be listened for, users can change the setting during a session, and animations should react to that immediately without requiring a page reload.
<!-- Alpine.js component that respects prefers-reduced-motion -->
<div
x-data="{
isVisible: false,
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
init() {
// Listen for runtime changes to the system preference
window.matchMedia('(prefers-reduced-motion: reduce)')
.addEventListener('change', (e) => {
this.prefersReducedMotion = e.matches;
});
// Intersection Observer for scroll-triggered reveal
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) this.isVisible = true;
});
}, { threshold: 0.15 });
observer.observe(this.$el);
}
}"
:class="{
'opacity-0 translate-y-4': !isVisible && !prefersReducedMotion,
'opacity-0': !isVisible && prefersReducedMotion,
'opacity-100 translate-y-0': isVisible
}"
class="transition-all duration-500 motion-reduce:transition-none"
>
<!-- Content appears with slide-up or fade depending on preference -->
<p>This content animates in, respecting motion preferences.</p>
</div>
8. motion-reduce vs. no animation at all: comparison
The motion-reduce variant is not the same as removing animations entirely for all users. The distinction matters: animations improve the user experience for the majority of users, provide visual feedback, and help communicate status changes. The goal is not to eliminate animation, but to reduce it for users who explicitly ask for that.
| Scenario | Without motion-reduce | With motion-reduce | Recommendation |
|---|---|---|---|
| Loading spinner | Spins continuously | Static, still visible | animate-none |
| Hover button | Translates upward | Color/opacity change only | transition-none, keep color |
| Parallax scroll | Background moves | Static background | JS check + background-attachment: fixed |
| Scroll reveal | Slide-in from below | Fade-in without displacement | animate-fade-in instead of animate-slide-in |
| Modal opening | Scale from 0.9 to 1 | Appears instantly | transition-none under motion-reduce |
9. WCAG 2.2 criterion 2.3.3 in practice
WCAG 2.2 criterion 2.3.3 "Animation from Interactions" sits at Level AAA, but the underlying principle, that users must be able to disable animations triggered by interaction, is a baseline requirement for accessible websites. Building motion-reduce into the Tailwind workflow is the most efficient way to meet this requirement without needing separate CSS files or JavaScript hacks.
For full WCAG compliance, motion-reduce: alone is not always enough. Animations that flash more than three times per second violate criterion 2.3.1 (Photosensitive Seizures) and must be avoided entirely, regardless of the motion-reduce setting. For screen reader users, it is also important that elements which communicate status purely through animation remain accessible through text as well. The motion-reduce variant solves the visual problem, but it does not replace properly implemented aria-live regions for status updates.
10. Summary
The Tailwind CSS motion-reduce variant is an essential tool for accessible frontend development. It makes it easy to respond to the prefers-reduced-motion system setting without writing media queries by hand. The pattern is consistent: define the full animation first, then set the reduced alternative with motion-reduce:. This approach protects users with vestibular disorders and satisfies WCAG 2.2 criterion 2.3.3.
For JavaScript-driven animations in Alpine.js and other frameworks, window.matchMedia('(prefers-reduced-motion: reduce)') must be queried separately. The motion-reduce principle technically only applies to CSS, responsibility for JavaScript animations rests with the developer. A thorough implementation checks both layers, reacts to changes in the system setting during a session, and is tested with real users or by emulating the setting through browser DevTools.
Tailwind CSS motion-reduce: the essentials at a glance
Prefix syntax
motion-reduce:animate-none, motion-reduce:transition-none directly in the markup. Reacts to the system's "reduce motion" setting.
Vestibular disorders
Parallax, pulse, rotation, and translate can trigger dizziness and nausea. motion-reduce protects affected users without any downside for others.
WCAG 2.2
Criterion 2.3.3 requires that interaction-triggered animations be disableable. motion-reduce is the most direct path to compliance.
JavaScript animations
Query window.matchMedia('(prefers-reduced-motion: reduce)') separately in Alpine.js and other frameworks. Listen for the change event to catch runtime changes.