Front-end Engineering Lab

Accessible Animations

Respect user motion preferences and avoid triggering vestibular disorders

Animations enhance UX but can cause motion sickness, vertigo, or seizures in some users. Respect prefers-reduced-motion and design animations responsibly.

The prefers-reduced-motion Media Query

/* Default: Full animations */
.element {
  animation: slide-in 0.3s ease-out;
}

/* Reduced motion: Minimal or no animation */
@media (prefers-reduced-motion: reduce) {
  .element {
    animation: none;
  }
}

React Hook

// hooks/useReducedMotion.ts
export function useReducedMotion() {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
    
    setPrefersReducedMotion(mediaQuery.matches);

    const handleChange = (e: MediaQueryListEvent) => {
      setPrefersReducedMotion(e.matches);
    };

    mediaQuery.addEventListener('change', handleChange);
    
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);

  return prefersReducedMotion;
}

// Usage
export function AnimatedComponent() {
  const prefersReducedMotion = useReducedMotion();

  return (
    <motion.div
      animate={{
        x: prefersReducedMotion ? 0 : 100,
        transition: { duration: prefersReducedMotion ? 0 : 0.3 },
      }}
    >
      Content
    </motion.div>
  );
}

Safe Animation Patterns

Fade Instead of Slide

// ❌ PROBLEMATIC: Large movement
export function SlideIn() {
  return (
    <motion.div
      initial={{ x: -200 }}
      animate={{ x: 0 }}
      transition={{ duration: 0.5 }}
    >
      Content
    </motion.div>
  );
}

// ✅ GOOD: Fade with minimal movement
export function FadeIn() {
  const prefersReducedMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, x: prefersReducedMotion ? 0 : -20 }}
      animate={{ opacity: 1, x: 0 }}
      transition={{ duration: prefersReducedMotion ? 0.1 : 0.3 }}
    >
      Content
    </motion.div>
  );
}

Scale with Caution

// ❌ PROBLEMATIC: Large scale changes
<motion.div
  whileHover={{ scale: 1.5 }}
  transition={{ duration: 0.2 }}
>
  Button
</motion.div>

// ✅ GOOD: Subtle scale or none
export function AccessibleButton() {
  const prefersReducedMotion = useReducedMotion();

  return (
    <motion.button
      whileHover={{
        scale: prefersReducedMotion ? 1 : 1.05,
      }}
      transition={{ duration: 0.15 }}
    >
      Button
    </motion.button>
  );
}

Framer Motion Integration

// Create accessibility-aware variants
const variants = {
  hidden: (prefersReducedMotion: boolean) => ({
    opacity: 0,
    y: prefersReducedMotion ? 0 : 20,
  }),
  visible: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.3,
    },
  },
};

export function AccessibleAnimation() {
  const prefersReducedMotion = useReducedMotion();

  return (
    <motion.div
      custom={prefersReducedMotion}
      initial="hidden"
      animate="visible"
      variants={variants}
    >
      Content
    </motion.div>
  );
}

CSS-Only Approach

/* Define animations */
@keyframes slide-in {
  from {
    transform: translateX(-100px);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.animated {
  animation: slide-in 0.3s ease-out;
}

/* Disable for reduced motion */
@media (prefers-reduced-motion: reduce) {
  .animated {
    animation: none;
    /* Optional: Instant appearance */
    opacity: 1;
    transform: none;
  }
  
  /* Or minimal fade */
  .animated {
    animation: fade-in 0.1s ease-out;
  }
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

Global Configuration

// utils/animation-config.ts
export function getAnimationDuration(base: number): number {
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  return prefersReducedMotion ? 0 : base;
}

export function getAnimationVariant(base: any, reduced: any) {
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  return prefersReducedMotion ? reduced : base;
}

// Usage
const duration = getAnimationDuration(300); // 0 or 300ms
const transform = getAnimationVariant({ x: -100 }, { x: 0 });

Loading Spinners

// Accessible loading spinner
export function LoadingSpinner() {
  const prefersReducedMotion = useReducedMotion();

  if (prefersReducedMotion) {
    // Static indicator for reduced motion
    return (
      <div role="status" aria-label="Loading">
        <div className="loading-static">⏳</div>
        <span className="sr-only">Loading...</span>
      </div>
    );
  }

  return (
    <div role="status" aria-label="Loading">
      <div className="loading-spinner" />
      <span className="sr-only">Loading...</span>
    </div>
  );
}

// CSS
.loading-spinner {
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .loading-spinner {
    animation: none;
    border-top-color: #3498db;
    opacity: 0.6;
  }
}

Parallax Scrolling

// Accessible parallax
export function ParallaxSection() {
  const [scrollY, setScrollY] = useState(0);
  const prefersReducedMotion = useReducedMotion();

  useEffect(() => {
    if (prefersReducedMotion) return;

    const handleScroll = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [prefersReducedMotion]);

  return (
    <div
      style={{
        transform: prefersReducedMotion
          ? 'none'
          : `translateY(${scrollY * 0.5}px)`,
      }}
    >
      Background Content
    </div>
  );
}

Avoiding Seizure Triggers

// NEVER flash more than 3 times per second
// NEVER use red flashing

// ❌ DANGEROUS: Rapid flashing
@keyframes dangerous-flash {
  0%, 50%, 100% { opacity: 1; }
  25%, 75% { opacity: 0; }
}

.dangerous {
  animation: dangerous-flash 0.5s infinite;
}

// ✅ SAFE: Slow, gentle fade
@keyframes safe-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.7; }
}

.safe {
  animation: safe-pulse 2s ease-in-out infinite;
}

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

Page Transitions

// components/PageTransition.tsx
export function PageTransition({ children }: Props) {
  const prefersReducedMotion = useReducedMotion();

  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={location.pathname}
        initial={{ opacity: prefersReducedMotion ? 1 : 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: prefersReducedMotion ? 1 : 0 }}
        transition={{ duration: prefersReducedMotion ? 0 : 0.3 }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

Hover Effects

/* Subtle, accessible hover effects */
.button {
  background: #0066cc;
  color: white;
  transition: background 0.2s ease;
}

.button:hover {
  background: #0052a3;
}

/* Disable transition for reduced motion */
@media (prefers-reduced-motion: reduce) {
  .button {
    transition: none;
  }
}

Auto-Playing Animations

// Provide controls for auto-playing animations
export function AutoPlayAnimation() {
  const [isPaused, setIsPaused] = useState(false);
  const prefersReducedMotion = useReducedMotion();

  // Auto-pause if reduced motion preferred
  useEffect(() => {
    if (prefersReducedMotion) {
      setIsPaused(true);
    }
  }, [prefersReducedMotion]);

  return (
    <div>
      <motion.div
        animate={{ rotate: isPaused ? 0 : 360 }}
        transition={{
          duration: 2,
          repeat: isPaused ? 0 : Infinity,
          ease: 'linear',
        }}
      >
        🔄
      </motion.div>
      
      <button onClick={() => setIsPaused(!isPaused)}>
        {isPaused ? 'Play' : 'Pause'} Animation
      </button>
    </div>
  );
}

Testing

// Test with reduced motion
describe('Accessible Animation', () => {
  it('respects prefers-reduced-motion', () => {
    // Mock reduced motion
    Object.defineProperty(window, 'matchMedia', {
      value: jest.fn().mockImplementation((query) => ({
        matches: query === '(prefers-reduced-motion: reduce)',
        media: query,
        addEventListener: jest.fn(),
        removeEventListener: jest.fn(),
      })),
    });

    render(<AnimatedComponent />);
    
    // Animation should be disabled or minimal
    const element = screen.getByTestId('animated');
    expect(element).not.toHaveClass('animating');
  });
});

// Manual testing:
// 1. Enable reduced motion in OS settings
//    - Mac: System Preferences > Accessibility > Display > Reduce motion
//    - Windows: Settings > Ease of Access > Display > Show animations
// 2. Verify animations are minimal/disabled

Best Practices

  1. Always respect prefers-reduced-motion
  2. Fade over slide when possible
  3. Keep animations short (< 300ms)
  4. Avoid rapid flashing (< 3Hz)
  5. No red flashing ever
  6. Provide pause controls for auto-play
  7. Test with motion disabled
  8. Use subtle hover effects
  9. Document motion in components
  10. Consider vestibular disorders

Safe Animation Guidelines

Duration

  • Normal: 200-400ms
  • Reduced: 0-100ms or none

Movement

  • Normal: Up to 50px translation
  • Reduced: 0-10px or none

Rotation

  • Normal: Up to 45°
  • Reduced: 0° or minimal

Scale

  • Normal: 0.95-1.1x
  • Reduced: 1x (no scale)

Common Pitfalls

Ignoring reduced motion: Causes motion sickness
Always check and respect preference

Large movements: Disorienting
Minimal movements, prefer fade

Rapid flashing: Seizure risk
Never flash > 3 times/second

No pause controls: Forced animation
Provide pause/play for auto-animations

Parallax everywhere: Overwhelming
Use sparingly, disable for reduced motion

Accessible animations enhance UX without harming users—always respect motion preferences and avoid triggering vestibular disorders!

On this page