Design animations that respect user motion preferences — implementing prefers-reduced-motion, identifying vestibular-triggering animations, and providing functional alternatives.
Design animations that respect user motion preferences — implementing prefers-reduced-motion, implementing prefers-reduced-motion, identifying vestibular-triggering animations, and providing functional alternatives.
Approximately 35% of adults over 40 have experienced vestibular disorders, and certain types of animation can trigger disorientation, nausea, and dizziness in these users. WCAG 2.3.3 (Animation from Interactions, Level AAA) prohibits motion animation from interactions unless it is essential to the functionality. Even at AA, the spirit of accessibility requires reducing harmful animations.
Animation types with high vestibular risk:
Low-risk animation types:
The prefers-reduced-motion media query reflects the user's OS-level 'reduce motion' preference. Implement it as the default path with motion as the opt-in:
1/* Default: no motion — safe for all users */
2.animated-element {
3 transition: none;
4 animation: none;
5}
6
7/* Motion opt-in: only for users who have NOT requested reduced motion */
8@media (prefers-reduced-motion: no-preference) {
9 .animated-element {
10 transition: transform 300ms ease-out, opacity 200ms ease;
11 }
12
13 .spinner {
14 animation: spin 1s linear infinite;
15 }
16
17 .hero-parallax {
18 transform: translateY(var(--parallax-offset));
19 transition: transform 0.1s linear;
20 }
21}
22
23/* Reduced motion alternatives: same information, no movement */
24@media (prefers-reduced-motion: reduce) {
25 /* Parallax: no movement, just show the background */
26 .hero-parallax { transform: none; }
27
28 /* Loading spinner: fade instead of rotate */
29 .spinner { animation: fade-pulse 1.5s ease-in-out infinite; }
30
31 /* Page transitions: instant instead of slide */
32 .page-transition { transition: opacity 0.1s linear; }
33
34 /* Scroll animations: show elements immediately */
35 .scroll-reveal { opacity: 1; transform: none; }
36}
37
38@keyframes fade-pulse {
39 0%, 100% { opacity: 1; }
40 50% { opacity: 0.5; }
41}CSS prefers-reduced-motion only affects CSS animations and transitions. JavaScript-controlled animations (GSAP, Framer Motion, custom requestAnimationFrame) require explicit checks:
1// Utility to check motion preference
2export function prefersReducedMotion(): boolean {
3 return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
4}
5
6// React hook that updates when preference changes
7export function usePrefersReducedMotion() {
8 const [prefersReduced, setPrefersReduced] = useState(
9 () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
10 );
11
12 useEffect(() => {
13 const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
14 const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
15 mq.addEventListener('change', handler);
16 return () => mq.removeEventListener('change', handler);
17 }, []);
18
19 return prefersReduced;
20}
21
22// Usage in a component with GSAP
23function AnimatedHero() {
24 const prefersReduced = usePrefersReducedMotion();
25 const timelineRef = useRef<GSAPTimeline | null>(null);
26
27 useEffect(() => {
28 if (prefersReduced) {
29 // Skip animation entirely — show final state immediately
30 gsap.set('.hero-text', { opacity: 1, y: 0 });
31 return;
32 }
33
34 timelineRef.current = gsap.timeline()
35 .from('.hero-text', { opacity: 0, y: 40, duration: 0.6 });
36 }, [prefersReduced]);
37}Scroll-triggered animations are among the most common vestibular triggers. Elements that move as you scroll combine two motion sources (the scrolling itself plus the element's animation) which amplifies the effect:
1// Intersection Observer: show/hide based on visibility, not scroll position
2function useScrollReveal(ref: React.RefObject<HTMLElement>) {
3 const prefersReduced = usePrefersReducedMotion();
4
5 useEffect(() => {
6 const el = ref.current;
7 if (!el) return;
8
9 if (prefersReduced) {
10 // Reduced motion: show element immediately, no animation
11 el.style.opacity = '1';
12 el.style.transform = 'none';
13 return;
14 }
15
16 // Full motion: fade in when visible
17 const observer = new IntersectionObserver(
18 ([entry]) => {
19 if (entry.isIntersecting) {
20 el.style.animation = 'fadeIn 0.4s ease forwards';
21 observer.unobserve(el);
22 }
23 },
24 { threshold: 0.1 }
25 );
26 observer.observe(el);
27 return () => observer.disconnect();
28 }, [ref, prefersReduced]);
29}For page transitions in Next.js or React Router, always provide a reduced motion alternative:
1function PageTransition({ children }: { children: React.ReactNode }) {
2 const prefersReduced = usePrefersReducedMotion();
3
4 // Slide transitions are vestibular triggers — fade instead
5 const transition = prefersReduced
6 ? { duration: 0.1 } // Nearly instant opacity change
7 : { type: 'spring', stiffness: 100 }; // Spring slide
8
9 return (
10 <motion.div
11 initial={{ opacity: prefersReduced ? 1 : 0, x: prefersReduced ? 0 : 100 }}
12 animate={{ opacity: 1, x: 0 }}
13 transition={transition}
14 >
15 {children}
16 </motion.div>
17 );
18}Test with prefers-reduced-motion enabled to verify your implementation:
macOS: System Settings > Accessibility > Display > Reduce Motion
Windows: Settings > Ease of Access > Display > Show animations (turn off)
iOS: Settings > Accessibility > Motion > Reduce Motion
Android: Settings > Accessibility > Remove animations (developer options)
In Chrome DevTools: Rendering > Emulate CSS media feature prefers-reduced-motion (set to 'reduce').
1# Playwright test for reduced motion compliance1// tests/motion/reduced-motion.spec.ts
2import { test, expect } from '@playwright/test';
3
4test.describe('Reduced motion', () => {
5 test.use({
6 // Enable reduced motion for all tests in this file
7 contextOptions: {
8 reducedMotion: 'reduce',
9 },
10 });
11
12 test('hero section has no parallax animation', async ({ page }) => {
13 await page.goto('/');
14 const hero = page.locator('.hero-parallax');
15 const transform = await hero.evaluate(el =>
16 window.getComputedStyle(el).transform
17 );
18 // Expect no transform (none or identity matrix)
19 expect(transform).toMatch(/none|matrix\(1, 0, 0, 1, 0, 0\)/);
20 });
21
22 test('page transition does not slide', async ({ page }) => {
23 await page.goto('/');
24 await page.click('[href="/about"]');
25 // Check that no translateX is applied during transition
26 const content = page.locator('main');
27 const transform = await content.evaluate(el =>
28 window.getComputedStyle(el).transform
29 );
30 expect(transform).toMatch(/none|matrix\(1, 0, 0, 1, 0, 0\)/);
31 });
32});Enable Reduce Motion in your OS settings. Navigate your entire product and identify every animation that still plays or remains jarring. Each one is a prefers-reduced-motion implementation gap — fix them prioritizing the highest motion-intensity animations first.
Your progress is saved in this browser, on this device only.