Type something to search...
How to Respect prefers-reduced-motion for Accessible Animations

How to Respect prefers-reduced-motion for Accessible Animations

Animation is one of the best tools we have for making an interface feel responsive and alive. A card that lifts on hover, a menu that slides into place, a hero that fades in on load: done well, motion guides attention and explains what just happened. But for a significant number of people, that same motion causes dizziness, nausea, headaches, or worse. The prefers-reduced-motion media query is how the operating system tells your site that the person in front of it has asked for less.

In this guide, I'll explain who needs reduced motion, how the media query works, and a set of practical patterns for respecting it without stripping your UI of all personality.

Who Needs Reduced Motion?

Vestibular disorders affect the inner ear and the brain's sense of balance and spatial orientation. For people with these conditions, large-scale motion on screen, especially parallax, zooming, and elements moving across the viewport, can trigger real physical symptoms: vertigo, nausea, and migraines that last well after they've closed the tab.

It isn't only vestibular conditions, either. People with migraine disorders, some people with ADHD or autism for whom motion is distracting, people recovering from concussions, and people who simply find a busy interface tiring all use this setting.

Every major operating system offers it:

  • macOS: System Settings, Accessibility, Display, Reduce motion
  • iOS and iPadOS: Settings, Accessibility, Motion, Reduce Motion
  • Windows: Settings, Accessibility, Visual effects, Animation effects (off)
  • Android: Settings, Accessibility, Remove animations (wording varies by manufacturer)
  • Many Linux desktops: an "enable animations" toggle in the desktop settings

When the user turns this on, browsers expose it through CSS and JavaScript.

The Media Query Basics

prefers-reduced-motion has two values:

  • no-preference: the user hasn't asked for anything.
  • reduce: the user wants less motion.
@media (prefers-reduced-motion: reduce) {
  .hero__title {
    animation: none;
  }
}

It's supported in all current major browsers and has been for years, so there's no reason to skip it.

The relevant accessibility guideline is WCAG 2.3.3 Animation from Interactions (Level AAA), which says motion triggered by interaction should be disable-able unless it's essential. Even if you're targeting Level AA, respecting this preference is a low-effort, high-impact habit, and some motion (anything that flashes more than three times a second) is covered by Level A requirements regardless.

Two Strategies: Opt-Out vs Opt-In

There are two ways to structure your CSS.

Opt-Out: Add Motion, Then Remove It

This is the most common approach. Write animations normally and turn them off inside a reduce query:

.toast {
  animation: slide-in 0.4s ease-out;
}

@keyframes slide-in {
  from {
    transform: translateY(100%);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .toast {
    animation: none;
  }
}

It's easy to retrofit into an existing codebase. The risk is that when someone adds a new animation later, they may forget to add the matching override.

Opt-In: Only Add Motion When It's Welcome

The alternative is to put motion inside a no-preference query, so it only exists when the user hasn't asked for less:

.toast {
  /* final state, no motion */
  opacity: 1;
}

@media (prefers-reduced-motion: no-preference) {
  .toast {
    animation: slide-in 0.4s ease-out;
  }
}

This is safer by default. If a new animation isn't wrapped, it's easy to spot in review, and browsers that don't support the query at all get the calm version. I prefer opt-in for new projects and opt-out for retrofitting.

The Global Safety Net

Many CSS resets include a blanket rule like this:

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

Why 0.01ms instead of none? Because some JavaScript waits for animationend or transitionend events. Setting a near-zero duration means the animation still technically runs and the events still fire, so the code doesn't hang.

This snippet is a good backstop, but it isn't a complete solution:

  • It removes all motion, including helpful, non-vestibular feedback like a color fade on a button.
  • It doesn't affect JavaScript animations (the Web Animations API, GSAP, canvas, or requestAnimationFrame loops).
  • It doesn't stop autoplaying video or animated GIFs.
  • !important makes it hard to deliberately keep a gentle animation.

Use it as a safety net, then handle the important cases deliberately.

Reduce, Don't Remove

The media query is called reduced motion, not no motion. The goal is to eliminate the kinds of movement that cause problems, while keeping feedback that helps people understand the interface.

Generally risky motion:

  • Large elements moving across the screen (slides, sweeps, fly-ins)
  • Parallax scrolling and background movement at different speeds
  • Zooming and scaling effects, especially full-screen ones
  • Spinning, rotation, and bouncing
  • Smooth scrolling over long distances
  • Autoplaying carousels and looping background video

Generally safe motion:

  • Opacity fades
  • Color and background-color transitions
  • Small, brief changes, like a focus ring appearing
  • Motion the user directly controls, such as dragging

So instead of turning a slide into nothing, turn it into a fade:

.drawer {
  transition:
    transform 0.3s ease,
    opacity 0.3s ease;
  transform: translateX(100%);
  opacity: 0;
}

.drawer.is-open {
  transform: translateX(0);
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .drawer {
    transition: opacity 0.2s ease;
    transform: none;
  }
}

With reduced motion, the drawer is always in its final position and simply fades in and out. The user still gets a clear cue that something opened.

Using Custom Properties for Motion Tokens

If your design system has motion tokens, you can centralize the preference in one place:

:root {
  --motion-duration: 250ms;
  --motion-distance: 24px;
  --motion-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-duration: 0ms;
    --motion-distance: 0px;
  }
}

.card {
  transition: transform var(--motion-duration) var(--motion-ease);
}

.card:hover {
  transform: translateY(calc(var(--motion-distance) * -0.25));
}

.reveal {
  animation: reveal var(--motion-duration) var(--motion-ease) both;
}

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(var(--motion-distance));
  }
}

With reduced motion, the distance drops to zero, so nothing moves. You can choose to keep a short duration for opacity changes instead of zero if you want the fade to remain.

Smooth Scrolling

scroll-behavior: smooth is lovely for jumping to anchors on a short page, but on a long page it creates a rush of movement. Only enable it when the user hasn't opted out:

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

The same applies to scrollIntoView and scrollTo in JavaScript. Pass behavior: "auto" when the user prefers reduced motion.

Scroll-Driven and View-Triggered Animations

Scroll-driven animations (animation-timeline: scroll() and view()) are powerful and increasingly common, and they're exactly the kind of motion that can bother people, since content moves as the page scrolls. They're supported in Chromium-based browsers and newer Safari releases, with Firefox support still in progress as of this writing, so they also need a @supports check.

.reveal-on-scroll {
  opacity: 1;
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal-on-scroll {
      animation: fade-up linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 30%;
    }
  }
}

@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
}

Without support, or with reduced motion on, the content is simply visible. Never make content depend on an animation to become visible, because if the animation doesn't run, the content stays hidden.

Handling Motion in JavaScript

CSS media queries don't reach animations you run from JavaScript. Use matchMedia and listen for changes:

const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");

function prefersReducedMotion() {
  return motionQuery.matches;
}

function openPanel(panel) {
  panel.hidden = false;

  if (prefersReducedMotion()) return;

  panel.animate(
    [
      { transform: "translateY(16px)", opacity: 0 },
      { transform: "translateY(0)", opacity: 1 },
    ],
    { duration: 250, easing: "ease-out" },
  );
}

motionQuery.addEventListener("change", () => {
  // pause or resume any running decorative loops here
});

Most animation libraries have a built-in hook for this. Check your library's docs for a reduced-motion option before writing your own.

Video, GIFs, and Carousels

These are the most common sources of problematic motion, and they need more than CSS.

Autoplay Video

Don't autoplay background video when reduced motion is on. Show the poster instead:

<video class="bg-video" muted loop playsinline poster="/images/loop-poster.jpg">
  <source src="/video/loop.mp4" type="video/mp4" />
</video>
<button class="bg-video__toggle" type="button">Pause background video</button>
const video = document.querySelector(".bg-video");
const toggle = document.querySelector(".bg-video__toggle");

if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
  video.play().catch(() => {});
} else {
  toggle.textContent = "Play background video";
}

toggle.addEventListener("click", () => {
  if (video.paused) {
    video.play();
    toggle.textContent = "Pause background video";
  } else {
    video.pause();
    toggle.textContent = "Play background video";
  }
});

Always provide a visible pause control for moving content that lasts more than five seconds. That's a WCAG Level A requirement (2.2.2 Pause, Stop, Hide), whether or not reduced motion is on.

Animated GIFs

You can swap an animated GIF for a static frame using the media attribute on <source>:

<picture>
  <source
    srcset="/images/demo-still.png"
    media="(prefers-reduced-motion: reduce)"
  />
  <img
    src="/images/demo.gif"
    alt="Dragging a card between columns on the board"
  />
</picture>

Carousels

Disable auto-advance when reduced motion is on, and switch slide transitions to an instant change or a quick fade.

Testing Reduced Motion

You don't need to change your OS setting every time:

  • Chrome and Edge DevTools: open the Command Menu, run "Show Rendering", and set "Emulate CSS media feature prefers-reduced-motion" to reduce.
  • Firefox: set ui.prefersReducedMotion to 1 in about:config.
  • Safari: recent versions of Web Inspector include media feature overrides; otherwise, flip the macOS Reduce motion setting, which Safari picks up immediately.

Then check the full journey: page load, navigation, menus, modals, scroll effects, and any JS-driven animation.

Offering an In-Site Toggle

Some users don't know the OS setting exists. You can offer a site-level toggle that defaults to the system preference and adds a class when chosen:

.reduce-motion *,
.reduce-motion *::before,
.reduce-motion *::after {
  animation-duration: 0.01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.01ms !important;
  scroll-behavior: auto !important;
}

Store the choice in localStorage and apply the class early, in a small inline script, to avoid a flash of animation on load.

Conclusion

Respecting prefers-reduced-motion is one of the simplest accessibility wins available. Use the opt-in pattern for new work, keep a global safety net, and replace large movement with fades rather than removing all feedback. Don't forget the things CSS can't reach: JavaScript animations, video, GIFs, and carousels.

Turn on reduced motion in your OS today and browse your own site. If anything still swoops, spins, or slides, you know where to start.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading