prefers-reduced-motion: Building Accessible CSS Animation
The prefers-reduced-motion media query detects a user's OS-level motion setting so CSS animations can be toned down or removed for people who need it.
prefers-reduced-motion is a CSS media query that reads a setting from the operating system — “Reduce motion” on macOS and iOS, “Show animations in Windows” on Windows, similar toggles on Android and most Linux desktops — and lets a stylesheet respond to it. When the user has asked their system for less motion, a site can drop parallax effects, cut auto-playing transitions, and swap sweeping page animations for instant or near-instant changes.
This isn’t a cosmetic nicety. For a meaningful share of users, large-scale motion on screen triggers real physical symptoms: dizziness, nausea, and headaches consistent with vestibular disorders. Others simply find animation distracting or draining. The media query gives you a reliable, standards-based way to detect the preference without asking the user to configure anything on your site.
The two values
@media (prefers-reduced-motion: reduce) {
/* tone down or remove motion here */
}
@media (prefers-reduced-motion: no-preference) {
/* full animation is fine */
}
reduce fires when the user has turned on their OS-level reduced-motion setting. no-preference fires when they haven’t — which is also the default if you write no media query at all. In practice, most teams write only the reduce block and let everything else fall through to whatever the base styles already do, rather than duplicating a no-preference block that just restates the defaults.
There’s no no-motionor “off” value — it’s a binary preference, reduce or don’t.
What “reduce” should actually mean
Reduced doesn’t have to mean removed. The right response depends on what the animation is doing:
- Decorative motion — parallax scrolling, floating background shapes, autoplaying carousels — should generally be disabled outright inside the
reduceblock. It carries no information, so there’s nothing lost by cutting it. - State-communicating motion — a menu sliding open, a modal fading in, a tab switching content — should keep some signal that something changed, but shrink the distance, duration, and easing curve. A cross-fade or a simple opacity change communicates the same state change without the sweeping movement.
- Progress and loading indicators — spinners, progress bars — are usually safe to leave alone since they’re functional rather than purely visual, though very fast pulsing effects are worth softening too.
A common, low-effort pattern is to shrink rather than eliminate:
.modal {
transition: transform 0.4s ease-out, opacity 0.4s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.modal {
transition-duration: 0.01ms !important;
}
}
Setting the duration to near-zero instead of using transition: none keeps transitionend events firing for any JavaScript that depends on them, which matters if your component logic waits for the transition to finish before doing something else — see how JavaScript’s event loop schedules those callbacks.
Pairing it with will-change and custom properties
If you’re already using CSS custom properties to centralize animation durations, prefers-reduced-motion becomes a one-line override instead of a query duplicated across every component:
:root {
--motion-duration: 0.4s;
--motion-distance: 24px;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration: 0.01ms;
--motion-distance: 0px;
}
}
.card {
transition: transform var(--motion-duration) ease-out;
transform: translateY(var(--motion-distance));
}
Every component that reads --motion-duration and --motion-distance gets the reduced treatment for free, without a per-component media query. This pairs naturally with CSS transitions and animations generally — reduced motion is a modifier on top of whichever mechanism you’re already using, not a separate animation system.
If you’re building richer, JavaScript-driven sequences — scroll-linked reveals via the scroll-driven animations APIs, or full-page transitions via the View Transitions API — the same media query still applies. Both can be paired with a matchMedia check in JavaScript to skip the animated path entirely rather than trying to shrink it after the fact.
Reading the preference in JavaScript
CSS media queries aren’t limited to stylesheets — window.matchMedia reads the same signal so animation logic driven by JavaScript (canvas effects, JS-triggered class toggles, scroll listeners) can branch on it too:
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReduced) {
el.classList.add("no-motion");
}
Because the OS setting can change while the page is open — a user might flip it in system settings without reloading — listen for changes rather than checking once:
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", (e) => {
document.documentElement.classList.toggle("reduce-motion", e.matches);
});
Common mistakes
- Treating it as a niche edge case. It’s a mainstream accessibility feature with broad browser support, not an experimental API. Test it the same way you’d test a dark-mode toggle.
- Only styling
.reduce-motionclasses added manually. If your only implementation path is a manual site preference toggle, users who set the OS-level flag and never visit your settings page get nothing. The media query should be the default path; a manual toggle is a nice addition, not a substitute. - Forgetting
prefers-reduced-motiondoesn’t mean “no interactivity.” Hover states, focus indicators — covered by:focus-visible— and instant state changes should stay exactly as responsive; only the animated transition between states is what shrinks. - Disabling motion inside JavaScript-heavy widgets but leaving CSS-driven ones untouched, or vice versa. Audit both layers; a reduced-motion user shouldn’t hit a jarring animated carousel just because it happened to be built with JavaScript instead of CSS.
The takeaway
prefers-reduced-motion: reduce is a direct read of a real, OS-level accessibility setting — treat it as a first-class part of your design system, not an afterthought. Route your animation durations and distances through custom properties so the override is a single block instead of a query scattered through every component, drop purely decorative motion outright, and shrink rather than eliminate motion that communicates state. It’s a small amount of CSS for a real reduction in how a meaningful share of your visitors experience your site.
Tagged
Keep reading
Takina · · 4 min read CSS object-fit and object-position, Explained
object-fit controls how an image or video is cropped inside its box, and object-position controls which part of it stays visible. How they work together.
Takina · · 5 min read CSS inherit, initial, unset & revert Explained
CSS's four global keywords control where a property's value comes from. How inherit, initial, unset, and revert differ, with a comparison table.
Takina · · 4 min read Dynamic Viewport Units: dvh, svh, and lvh Explained
dvh, svh, and lvh fix the classic mobile vh bug where browser toolbars cut off full-height layouts. Here's what each unit measures and when to use it.