Animations are a common accessibility problem. For users with vestibular disorders, epilepsy, or motion sensitivity, autoplay animations can cause physical discomfort or make content unusable. This guide covers every accessibility consideration for Lottie animations.
The Core Problem
WCAG 2.1 Success Criterion 2.2.2 (Pause, Stop, Hide) requires that any moving or blinking content that:
- Lasts more than 5 seconds
- Is presented alongside other content
...must provide a way to pause, stop, or hide it.
WCAG 2.3.3 (Animation from Interactions) requires that motion triggered by interaction can be disabled.
Lottie animations autoplay by default. Without explicit accessibility handling, they violate both criteria.
Preview Before You Build
Always audit your animations first in IconKing:
- See exact motion, speed, and timing
- Identify animations that might trigger motion sensitivity (fast movement, parallax, large-scale motion)
- Verify the animation renders correctly before adding it to your site
1. Respect prefers-reduced-motion
The prefers-reduced-motion CSS media query tells you whether the user has requested less motion in their OS settings. Always check it.
CSS Approach (simplest)
For Lottie rendered as SVG, you can pause via CSS:
@media (prefers-reduced-motion: reduce) {
/* Pause CSS animations on the SVG */
.lottie-container svg * {
animation: none !important;
transition: none !important;
}
}
However: This doesn't stop Lottie's JavaScript rendering loop. Use the JavaScript approach below instead.
JavaScript Approach (correct)
import lottie from 'lottie-web';
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const anim = lottie.loadAnimation({
container: document.getElementById('animation'),
renderer: 'svg',
loop: !prefersReducedMotion,
autoplay: !prefersReducedMotion,
path: '/animations/hero.json',
});
// If reduced motion: jump to the final frame (show static state)
if (prefersReducedMotion) {
anim.addEventListener('DOMLoaded', () => {
anim.goToAndStop(anim.totalFrames - 1, true);
});
}
This shows the last frame of the animation as a static image when the user prefers reduced motion â better UX than showing nothing.
React Hook: useReducedMotion
import { useState, useEffect } from 'react';
export function useReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
});
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
function handleChange(e: MediaQueryListEvent) {
setReducedMotion(e.matches);
}
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, []);
return reducedMotion;
}
import Lottie from 'lottie-react';
import { useReducedMotion } from './hooks/useReducedMotion';
export function AccessibleAnimation({ animationData }) {
const reducedMotion = useReducedMotion();
return (
<Lottie
animationData={animationData}
loop={!reducedMotion}
autoplay={!reducedMotion}
// Show static last frame if reduced motion
initialSegment={reducedMotion ? undefined : undefined}
/>
);
}
Vue Composable: useReducedMotion
// composables/useReducedMotion.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useReducedMotion() {
const reducedMotion = ref(false)
let mq: MediaQueryList
onMounted(() => {
mq = window.matchMedia('(prefers-reduced-motion: reduce)')
reducedMotion.value = mq.matches
const handler = (e: MediaQueryListEvent) => {
reducedMotion.value = e.matches
}
mq.addEventListener('change', handler)
onUnmounted(() => mq.removeEventListener('change', handler))
})
return { reducedMotion }
}
2. Add ARIA Labels
Lottie renders as SVG or Canvas â neither conveys meaning to screen readers by default. Always add ARIA attributes:
Decorative Animations (no semantic meaning)
If the animation is purely decorative (background, ambient motion), hide it from screen readers:
<div
id="animation"
aria-hidden="true"
role="presentation"
></div>
<div aria-hidden="true" style={{ width: 200, height: 200 }}>
<Lottie animationData={animData} loop autoplay />
</div>
Meaningful Animations (communicates state or content)
If the animation communicates something (loading, success, error), label it:
<div
role="img"
aria-label="Loading, please wait"
style={{ width: 48, height: 48 }}
>
<Lottie animationData={loadingAnim} loop autoplay />
</div>
For state-changing animations (loading â success):
type Status = 'loading' | 'success' | 'error';
const statusLabels: Record<Status, string> = {
loading: 'Loading, please wait',
success: 'Successfully submitted',
error: 'An error occurred',
};
export function StatusAnimation({ status }: { status: Status }) {
return (
<div
role="status"
aria-live="polite"
aria-label={statusLabels[status]}
style={{ width: 48, height: 48 }}
>
{status === 'loading' && <Lottie animationData={loadingAnim} loop autoplay />}
{status === 'success' && <Lottie animationData={successAnim} loop={false} autoplay />}
{status === 'error' && <Lottie animationData={errorAnim} loop={false} autoplay />}
</div>
);
}
aria-live="polite" tells screen readers to announce the status change when the user is idle.
3. Provide Pause Controls
For decorative background animations that run continuously, provide a visible pause button:
import { useState, useRef } from 'react';
import Lottie, { LottieRefCurrentProps } from 'lottie-react';
export function ControlledAnimation({ animationData }) {
const [paused, setPaused] = useState(false);
const lottieRef = useRef<LottieRefCurrentProps>(null);
function togglePause() {
if (paused) {
lottieRef.current?.play();
} else {
lottieRef.current?.pause();
}
setPaused(!paused);
}
return (
<div>
<div style={{ width: 300, height: 300 }}>
<Lottie
lottieRef={lottieRef}
animationData={animationData}
loop
autoplay
/>
</div>
<button
onClick={togglePause}
aria-label={paused ? 'Play animation' : 'Pause animation'}
aria-pressed={paused}
>
{paused ? 'ⶠPlay' : '⸠Pause'}
</button>
</div>
);
}
WCAG 2.2.2 requires this control for animations longer than 5 seconds.
4. Keyboard Navigation
Ensure animated interactive elements (buttons with Lottie icons) are keyboard accessible:
export function AnimatedButton({ onPress, label }) {
const lottieRef = useRef(null);
function handleActivate() {
lottieRef.current?.stop();
lottieRef.current?.play();
onPress?.();
}
return (
<button
onClick={handleActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleActivate();
}
}}
aria-label={label}
>
<div style={{ width: 24, height: 24 }} aria-hidden="true">
<Lottie
lottieRef={lottieRef}
animationData={iconAnim}
autoplay={false}
loop={false}
/>
</div>
</button>
);
}
Key points:
-
aria-labelon the button (not the Lottie wrapper) describes the action -
aria-hidden="true"on the animation div so screen readers don't read "animation" - Keyboard handler for Enter and Space (native
<button>handles this automatically, but explicit handlers help in some implementations)
5. Epilepsy and Seizure Considerations
WCAG 2.3.1 prohibits content that flashes more than 3 times per second. Lottie animations with rapid flashing effects violate this.
Before deploying:
- Open animations in IconKing and watch for ieapid color changes or flashing
- If flashing is present, ask the designer to slow down the frame rate or remove the effect
- Generally avoid animations with bright, rapidly alternating colors
The Photosensitive Epilepsy Analysis Tool (PEAT) can analyze video exports of animations if you need formal compliance testing.
6. Focus Indicators on Animated Elements
Don't rely on animations to indicate focus. Always ensure visible CSS focus indicators:
.animated-button:focus-visible {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
Complete Accessible Lottie Component
Putting it all together:
import { useRef } from 'react';
import Lottie, { LottieRefCurrentProps } from 'lottie-react';
import { useReducedMotion } from './hooks/useReducedMotion';
interface AccessibleLottieProps {
animationData: object;
ariaLabel?: string;
decorative?: boolean;
width?: number;
height?: number;
loop?: boolean;
}
export function AccessibleLottie({
animationData,
ariaLabel,
decorative = false,
width = 200,
height = 200,
loop = true,
}: AccessibleLottieProps) {
const reducedMotion = useReducedMotion();
const lottieRef = useRef<LottieRefCurrentProps>(null);
return (
<div
style={{ width, height }}
role={decorative ? 'presentation' : 'img'}
aria-hidden={decorative ? true : undefined}
aria-label={!decorative ? ariaLabel : undefined}
>
<Lottie
lottieRef={lottieRef}
animationData={animationData}
loop={reducedMotion ? false : loop}
autoplay={!reducedMotion}
/>
</div>
);
}
// Decorative (hide from screen readers)
<AccessibleLottie animationData={bgAnim} decorative />
// Meaningful (announce to screen readers)
<AccessibleLottie
animationData={loadingAnim}
ariaLabel="Loading, please wait"
loop
/>
Quick Accessibility Checklist
| Check | Implementation |
|---|---|
| Respects prefers-reduced-motion | window.matchMedia('(prefers-reduced-motion: reduce)') |
| Decorative animations hidden | aria-hidden="true" |
| Meaningful animations labeled |
role="img" + aria-label
|
| Status animations announced | aria-live="polite" |
| Continuous animations have pause control | Button with aria-label + aria-pressed
|
| No rapid flashing | Review in IconKing |
| Animated buttons keyboard-accessible | Native <button> + aria-label
|
Summary
- Always check
prefers-reduced-motionand stop/pause animations whentrue - Show the final frame as a static image instead of hiding the animation entirely
- Decorative animations:
aria-hidden="true"â no screen reader noise - Meaningful animations:
role="img"+aria-labelâ communicate state - Continuous background animations need a pause button (WCAG 2.2.2)
- Preview and audit animation motion in IconKing before shipping
Top comments (0)