Animations that ignore prefers-reduced-motion
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.
// 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: reduceWhy
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.
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 },
);
});
});