SalakCode SalakCode
Accessibility

Motion Preferences

Respect user preferences for animations

Intermediate
a11y animation prefers-reduced-motion css

Definition

The prefers-reduced-motion media query allows websites to respect user preferences for motion and animations. Some users experience vestibular disorders, motion sickness, or simply prefer less animation. This CSS feature enables you to provide alternative experiences that don’t trigger these issues.

The Problem

Motion Sensitivity

/* Some users experience: */
- Vertigo from parallax scrolling
- Nausea from zoom animations
- Dizziness from spinning elements
- Disorientation from rapid transitions

Operating System Settings

macOS: System Preferences > Accessibility > Display > Reduce motion
Windows: Settings > Ease of Access > Display > Show animations
iOS: Settings > Accessibility > Motion > Reduce Motion
Android: Settings > Accessibility > Remove animations

Implementing prefers-reduced-motion

CSS Approach

/* Default: Animations enabled */
.modal {
  animation: slideIn 0.3s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  /* User prefers less motion */
  .modal {
    animation: none;
    opacity: 1;
  }
  
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Granular Control

/* Respect preference per component */
.animated-card {
  transition: transform 0.3s ease;
}

@media (prefers-reduced-motion: reduce) {
  .animated-card {
    transition: opacity 0.2s ease; /* Safer alternative */
  }
}

/* Or disable entirely */
.parallax-bg {
  transform: translateY(calc(var(--scroll) * 0.5));
}

@media (prefers-reduced-motion: reduce) {
  .parallax-bg {
    transform: none; /* Static fallback */
  }
}

JavaScript Detection

Match Media

// Check user preference
const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

// Use in animations
if (!prefersReducedMotion) {
  // Play animation
  gsap.to('.element', { x: 100, duration: 1 });
} else {
  // Instant state change
  gsap.set('.element', { x: 100 });
}

React Hook

function usePrefersReducedMotion() {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
  
  useEffect(() => {
    const mediaQuery = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    );
    
    setPrefersReducedMotion(mediaQuery.matches);
    
    const listener = (event) => {
      setPrefersReducedMotion(event.matches);
    };
    
    mediaQuery.addEventListener('change', listener);
    return () => mediaQuery.removeEventListener('change', listener);
  }, []);
  
  return prefersReducedMotion;
}

// Usage
function AnimatedModal({ isOpen }) {
  const prefersReducedMotion = usePrefersReducedMotion();
  
  return (
    <motion.div
      initial={{ opacity: 0, y: 50 }}
      animate={{ 
        opacity: isOpen ? 1 : 0,
        y: isOpen ? 0 : 50 
      }}
      transition={
        prefersReducedMotion 
          ? { duration: 0 } 
          : { duration: 0.3 }
      }
    />
  );
}

Common Patterns

Safe Alternatives

/* Slide in animation */
.modal {
  animation: slideIn 0.3s ease-out;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Reduced motion: just fade */
@media (prefers-reduced-motion: reduce) {
  @keyframes slideIn {
    from { opacity: 0; }
    to { opacity: 1; }
  }
}

Scroll Animations

// Intersection Observer with reduced motion check
function setupScrollAnimations() {
  const prefersReducedMotion = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;
  
  if (prefersReducedMotion) {
    // Show all elements immediately
    document.querySelectorAll('.reveal').forEach(el => {
      el.classList.add('is-visible');
    });
    return;
  }
  
  // Normal scroll animations
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');
      }
    });
  });
  
  document.querySelectorAll('.reveal').forEach(el => {
    observer.observe(el);
  });
}

Video/GIF Autoplay

// Respect reduced motion for autoplay
function shouldAutoplay() {
  const prefersReducedMotion = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;
  
  return !prefersReducedMotion;
}

// Usage
<video
  autoPlay={shouldAutoplay()}
  muted
  loop
  poster="thumbnail.jpg"
>
  <source src="video.mp4" />
</video>

WCAG Guidelines

Success Criterion 2.2.2 (Pause, Stop, Hide)

/* Moving, blinking, scrolling content must be stoppable */

/* Provide pause mechanism */
.animated-banner {
  animation: scroll 10s linear infinite;
}

.paused {
  animation-play-state: paused;
}

Success Criterion 2.3.3 (Animation from Interactions)

// Motion triggered by user interaction
// Must respect prefers-reduced-motion

button.addEventListener('click', () => {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    // No animation
    showContent();
  } else {
    // Animate
    animateAndShowContent();
  }
});

Best Practices

1. Essential vs Decorative

/* Essential motion: Keep but simplify */
.loading-spinner {
  animation: spin 1s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .loading-spinner {
    animation: pulse 2s ease-in-out infinite;
    /* Still shows activity, but calmer */
  }
}

/* Decorative motion: Can disable */
.floating-decoration {
  animation: float 3s ease-in-out infinite;
}

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

2. Testing

// Test with reduced motion enabled
// Chrome DevTools: Rendering tab > Emulate CSS media feature

// Force for testing
document.documentElement.style.setProperty(
  '--prefers-reduced-motion',
  'reduce'
);

3. Progressive Enhancement

/* Start without motion */
.element {
  opacity: 0;
}

/* Add motion if user prefers */
@media (prefers-reduced-motion: no-preference) {
  .element {
    animation: fadeIn 0.5s ease forwards;
  }
}

/* Or use a class */
@media (prefers-reduced-motion: no-preference) {
  .animate-on-scroll {
    opacity: 0;
    transform: translateY(20px);
  }
  
  .animate-on-scroll.is-visible {
    animation: fadeUp 0.5s ease forwards;
  }
}
Key Takeaway

Always respect prefers-reduced-motion. Provide instant state changes as alternatives to animations. Test your site with reduced motion enabled. Remember that motion preferences are about accessibility, not just aesthetics.

Resources

Related Topics