skip to content

Operating systems expose a "reduce motion" accessibility setting, which the web surfaces as the CSS media feature prefers-reduced-motion. What does a page owe a user who has enabled it, and how do you honour it for both CSS animation and JavaScript-driven motion?

level: middleimportance: should knowfreq 48%

answer

  1. an OS accessibility preference, surfaced in CSS
  2. reduce, not remove
  3. keep the change, drop the journey
  4. also readable from script
  5. matchMedia plus a change listener

basics

~20 s

Reduce large or unexpected movement — parallax, zooms, sliding transitions, autoplaying loops — while keeping the feedback that tells users what changed, usually as an opacity crossfade. Honour it in CSS with a prefers-reduced-motion media query and in JavaScript by checking the same query through matchMedia.

solid answer

~50 s

The setting is an OS-level accessibility preference — motion can trigger nausea, dizziness and migraines for people with vestibular conditions — and the browser exposes it as the CSS media feature `prefers-reduced-motion`, with values `reduce` and `no-preference`. The obligation is *reduce*, not *remove*: strip the large-area, unexpected motion (parallax, zooms, big translations, autoplaying carousels, spinning loops) and keep the state-change feedback, typically downgrading a slide to a short opacity crossfade so the user still sees that something happened. In CSS, wrap the motion in `@media (prefers-reduced-motion: reduce)` overrides, or better, define motion only inside `@media (prefers-reduced-motion: no-preference)` so the calm version is the default. In JavaScript, read the same signal with `window.matchMedia('(prefers-reduced-motion: reduce)').matches` before starting a `requestAnimationFrame` loop or an `Element.animate()` call, and listen for its `change` event so the page reacts if the preference flips mid-session.

code

css · 20 lines
css
/* Motion is opt-in: the calm version is the default. */
.drawer {
  opacity: 0;
  transition: opacity 120ms linear;
}

.drawer[data-open] {
  opacity: 1;
}

@media (prefers-reduced-motion: no-preference) {
  .drawer {
    transform: translateX(-100%);
    transition: transform 300ms ease-out, opacity 300ms ease-out;
  }

  .drawer[data-open] {
    transform: none;
  }
}

go deeper

for a junior

Know that prefers-reduced-motion comes from an operating-system accessibility setting, that it takes the values reduce and no-preference, and that you honour it with a media query around your transitions.

for a middle

Explain the mechanics both ways: the media-query override in CSS, the no-preference default-calm shape, and reading the same query with matchMedia plus its change event so JavaScript-driven motion respects it too.

for a senior

Show judgment about what survives — keep the signal that state changed, usually as an opacity crossfade, and cut large-area or unrequested motion. Be ready to explain why a blanket kill-switch can break code waiting on transition events.

for a principal

Own it at the system level: motion tokens with a reduced variant built in, a default that is calm unless motion is explicitly opted into, and review or test coverage so new components cannot ship a reduced-motion path nobody has looked at.

## What the setting is Every major operating system has an accessibility control for motion — "Reduce motion" on macOS and iOS, "Show animations in Windows" on Windows, "Remove animations" on Android. Browsers surface it to pages as a CSS media feature: ```css @media (prefers-reduced-motion: reduce) { /* user asked for less motion */ } @media (prefers-reduced-motion: no-preference) { /* default, no request made */ } ``` The two values are the whole vocabulary. There is no "a bit less" tier, so the design decision is yours. ## Why it exists For people with vestibular disorders, large or unexpected movement on screen can cause genuine nausea, dizziness and headaches; it is also a migraine and, in extreme cases, a seizure trigger. The pattern that causes harm is not "an animation exists" — it is large-area movement, especially movement the user did not initiate: full-screen parallax, background video, zoom-and-pan transitions, sliding page changes, autoplaying carousels, continuous spinners in the periphery. WCAG's success criterion 2.3.3, *Animation from Interactions*, is the formal version: motion animation triggered by interaction should be disableable unless the animation is essential to the functionality. ## Reduce is not remove The most common mistake is treating the preference as "turn off all animation". Motion often carries meaning — it tells you a panel came from the button you pressed, that an item was deleted rather than teleported away, that a list reordered rather than re-rendered. Deleting it wholesale makes the interface *harder* to follow for everyone, including the user who asked for less motion. The working rule: **keep the fact that something changed, drop the journey.** A drawer that slides 400 px becomes a 120 ms opacity crossfade. A card that flies across the grid simply fades in place. Colour and opacity changes are generally safe; large translations, scale and rotation are not. What to strip outright: parallax and scroll-driven movement, autoplaying carousels and background video, decorative looping motion, exaggerated easing that overshoots, and any transition covering a large fraction of the viewport. ## Honouring it in CSS The override form is straightforward: ```css .drawer { transition: transform 300ms ease-out; } @media (prefers-reduced-motion: reduce) { .drawer { transition: opacity 120ms linear; transform: none; } } ``` A more robust shape is to make the calm version the default and add motion only when no preference is expressed: ```css @media (prefers-reduced-motion: no-preference) { .drawer { transition: transform 300ms ease-out; } } ``` This way any new component you forget to audit ships without motion for the users who care, instead of shipping with motion and needing an override. ## The blanket kill-switch, and its trap A global reset is a popular safety net: ```css @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; } } ``` Note why it uses a near-zero duration rather than `animation: none`: JavaScript that waits for `transitionend` or `animationend` before, say, removing a modal from the DOM still receives its event. Setting `none` cancels the animation outright and those handlers never fire, which turns an accessibility fix into a stuck-UI bug. Use the reset as a floor, not as the whole strategy — it produces a jarringly instant interface, and hand-tuned crossfades are better wherever the component matters. ## Honouring it in JavaScript Media queries are readable from script, and the same query is the source of truth: ```js const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); function reveal(el) { if (reduce.matches) { el.style.opacity = '1'; return; } el.animate([{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], { duration: 250, easing: 'ease-out', fill: 'both' }); } reduce.addEventListener('change', () => stopDecorativeLoops()); ``` The same check gates `requestAnimationFrame`-driven motion, scroll-linked effects, canvas ambience and third-party animation libraries. Listening for `change` matters because the user can toggle the OS setting while your single-page app is open, and a long-lived tab should react rather than wait for a reload. ## Testing it Do not rely on flipping your own OS setting. Chrome DevTools can emulate the CSS media feature from the Rendering drawer, so you can flip it per tab, and the query is easy to force in tests. Audit the reduced-motion path deliberately: it is the variant nobody looks at, so it is where a component ends up with a transition to nowhere or a crossfade that never resolves.

  • Why does the common reduced-motion CSS reset use a 0.01 ms duration instead of animation: none?
    Because code often waits for `transitionend` or `animationend` before continuing — removing a modal, unlocking a button, cleaning up a node. A near-zero duration still fires those events, so the sequence completes instantly and correctly. `none` cancels the animation and the events never arrive, leaving the UI stuck in a half-finished state.
  • The preference is only a hint about motion. Should you also drop a loading spinner?
    Generally no. A spinner conveys state the user needs, and its motion is small and localised, which is not the pattern that causes discomfort. If it is large, full-screen or heavily animated, swap it for a calmer indicator — a pulsing bar or static text — rather than removing the feedback entirely.
  • How would you test the reduced-motion variant without changing your operating system settings?
    Chrome DevTools can emulate the media feature per tab from the Rendering drawer, so you can flip between `reduce` and `no-preference` while inspecting. In automated tests, drive it through the browser's emulation hook and assert the calm path — that the element reaches its final state and that any completion handler still runs.

saying these in an interview costs you the question

  • Removes every animation, including feedback that explains what changed
  • Treats it as a browser-only toggle rather than an operating-system preference
  • Checks it once at load and ignores the change event
  • Uses animation: none and breaks code waiting on animationend
  • Assumes JavaScript animation is exempt because the query is a CSS feature

context