JM Family Design System

Animations that ignore prefers-reduced-motion

highMotionaccessibilitymotionWCAG-2.3.3vestibular

JavaScript-driven animations that don't check the user's reduced-motion preference will fire for users who have explicitly asked their device to hold the motion.

The wrong way

A scroll-triggered fade-in that runs unconditionally, with no check for the user preference and no fallback for users who can't tolerate the motion.

tsdo not copy
// Animate every card as it enters the viewport
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    entry.target.animate(
      [
        { opacity: 0, transform: 'translateY(40px)' },
        { opacity: 1, transform: 'translateY(0)' },
      ],
      { duration: 600 },
    );
  });
});
// Runs for every user, including those with prefers-reduced-motion: reduce

Why

A non-trivial slice of users — people with vestibular disorders, attention sensitivities, or simply low tolerance for movement on screen — has gone into their device settings and explicitly asked for motion to be reduced. Ignoring that preference is the digital equivalent of cranking the speakers up after someone has asked you to keep it down. WCAG 2.3.3 (Animation from Interactions, Level AAA) treats this as a baseline expectation. The global CSS rule in our system covers CSS transitions and animations automatically, but JavaScript-driven motion has to opt in.

The right way

Check the media query before running JavaScript-driven motion. Skip the animation entirely (or jump to the end state) when the user prefers reduced motion.

ts
const prefersReduced = window.matchMedia(
  '(prefers-reduced-motion: reduce)',
).matches;

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    if (prefersReduced) {
      (entry.target as HTMLElement).style.opacity = '1';
      return;
    }
    entry.target.animate(
      [
        { opacity: 0, transform: 'translateY(40px)' },
        { opacity: 1, transform: 'translateY(0)' },
      ],
      { duration: 600 },
    );
  });
});

Related

Added in 1.2.0Last reviewed 2026-05-15