Motion and Animation Accessibility
Implement animations that respect user motion preferences, avoid seizure triggers, and provide pause controls
When to Use
- Adding animations, transitions, or motion effects to a UI
- Building auto-playing carousels, videos, or background animations
- Implementing page transitions in a SPA
- Using parallax scrolling or scroll-triggered animations
- Reviewing existing animations for accessibility compliance
Instructions
- Respect
prefers-reduced-motion. Users who enable "Reduce motion" in their OS settings have vestibular disorders or motion sensitivity. Query this preference and eliminate or simplify animations.
/* Default: full animations */
.card {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.card:hover {
transform: scale(1.05);
}
/* Reduced motion: remove or simplify */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}
- Use a motion-first or reduce-first strategy. Choose one approach and apply it consistently:
/* Approach A: motion-first (add animations, remove for reduced-motion) */
.element {
animation: slide-in 0.5s ease;
}
@media (prefers-reduced-motion: reduce) {
.element {
animation: none;
}
}
/* Approach B: reduce-first (no animations by default, add for no-preference) */
.element {
animation: none;
}
@media (prefers-reduced-motion: no-preference) {
.element {
animation: slide-in 0.5s ease;
}
}
Approach B is safer — users get no animation by default.
- In JavaScript, check the preference before triggering animations.
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function animateElement(element: HTMLElement) {
if (prefersReducedMotion) {
// Instant state change, no animation
element.style.opacity = '1';
return;
}
element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, easing: 'ease-in' });
}
- In React, create a hook for motion preferences.
function usePrefersReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
useEffect(() => {
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// Usage
function AnimatedCard() {
const reduceMotion = usePrefersReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.3 }}
/>
);
}
- Provide pause, stop, or hide controls for auto-playing content. Any content that moves, blinks, or auto-updates for more than 5 seconds must have user controls (WCAG 2.2.2).
function AutoCarousel({ slides }: { slides: Slide[] }) {
const [isPaused, setIsPaused] = useState(false);
return (
<div>
<button
=> setIsPaused(!isPaused)}
aria-label={isPaused ? 'Play slideshow' : 'Pause slideshow'}
>
{isPaused ? <PlayIcon /> : <PauseIcon />}
</button>
<Carousel autoPlay={!isPaused} slides={slides} />
</div>
);
}
Avoid flashing content. Content that flashes more than 3 times per second can trigger seizures (WCAG 2.3.1 — Three Flashes). This is a hard constraint with no workaround.
Avoid large-scale motion. Parallax effects, zooming transitions, and full-page scroll animations are common vestibular triggers. When using these, always check
prefers-reduced-motionand provide a static alternative.Keep essential animations short. Transitions that communicate state changes (loading, success, error) should be under 500ms. Long, elaborate animations add cognitive load and frustrate users who interact frequently.
Use
will-changeand GPU-composited properties for performance. Janky animations that drop frames are worse than no animation. Stick totransformandopacityfor smooth 60fps animations.
.animated {
will-change: transform, opacity;
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
Details
WCAG requirements:
- 2.2.2 Pause, Stop, Hide: Auto-playing content that lasts more than 5 seconds must have controls
- 2.3.1 Three Flashes: No content flashes more than 3 times per second
- 2.3.3 Animation from Interactions (AAA): Motion triggered by user interaction can be disabled
What counts as "reduced motion":
- Remove: parallax scrolling, auto-playing animations, bouncing/shaking effects, zoom transitions
- Keep: opacity fades (instant or very fast), color changes, layout reflows
- Simplify: Replace slide transitions with fade, replace bounce with ease
Framer Motion integration:
<motion.div
layout
transition={{
layout: { duration: prefersReduced ? 0 : 0.3 },
}}
/>
Common mistakes:
- Checking
prefers-reduced-motiononce at load (user can change it at runtime — useaddEventListener) - Removing only CSS animations while JavaScript-driven animations still run
- Interpreting "reduced motion" as "no animation" (subtle fades and opacity changes are usually fine)
- Auto-playing video backgrounds without a pause button
Source
https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.