DEV Community

Fazal Shah
Fazal Shah

Posted on

Lottie Animations and Accessibility: prefers-reduced-motion, ARIA, and Best Practices

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
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}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
<div aria-hidden="true" style={{ width: 200, height: 200 }}>
  <Lottie animationData={animData} loop autoplay />
</div>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • aria-label on 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;
}
Enter fullscreen mode Exit fullscreen mode

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
 />
Enter fullscreen mode Exit fullscreen mode

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-motion and stop/pause animations when true
  • 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)