PatternsAccessibility
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/disabledBest Practices
- Always respect
prefers-reduced-motion - Fade over slide when possible
- Keep animations short (< 300ms)
- Avoid rapid flashing (< 3Hz)
- No red flashing ever
- Provide pause controls for auto-play
- Test with motion disabled
- Use subtle hover effects
- Document motion in components
- 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!