Skip to main content

Systematic prefers-reduced-motion Across a Component Library

Apr 8, 20262 min readAccessibility, Tailwind CSS, Design Systems, CSS

The Audit

Our shared-ui library had 30+ components. Twelve of them used CSS animations or transitions: Spinner, Loading, ProgressBar, Switch, Sidebar, ThemeToggle, Button, Skeleton, Dropdown, Toast, Modal, and Tooltip. None of them respected prefers-reduced-motion.

A global CSS rule like @media (prefers-reduced-motion: reduce) { * { animation: none !important; } } would have been a one-line fix. But it's a blunt instrument. It removes all animation indiscriminately, including transitions that communicate state changes. We needed a component-level approach.

Decorative vs. Functional

The audit forced a classification for every animation in the library.

Decorative animations exist for visual polish. Removing them changes nothing about the user's understanding of the interface:

ComponentAnimationVerdict
Spinneranimate-spinDecorative
Loadingbouncing dotsDecorative
Skeletonanimate-pulseDecorative
ProgressBarindeterminate fillDecorative

Functional transitions communicate state changes. Removing them requires a static alternative so the user still understands what happened:

ComponentAnimationVerdict
Switchthumb slideFunctional (position snap)
Sidebarpanel slideFunctional (instant show)
Toastentry/exit slideFunctional (instant show)
Dropdownmenu slide-downFunctional (instant show)

For decorative animations, motion-reduce:animate-none is sufficient. For functional transitions, motion-reduce:transition-none snaps the element to its final state instantly.

The Tailwind Pattern

Tailwind CSS 4's motion-reduce: variant maps directly to @media (prefers-reduced-motion: reduce). We applied it at the component level, next to the animation it modifies:

// Spinner — decorative, safe to freeze
<div
  className='inline-block rounded-full animate-spin motion-reduce:animate-none'
  role='status'
  aria-label={ariaLabel}
/>
// Loading — decorative bouncing dots
<div className='size-2 bg-brand-500 rounded-full animate-[bounceSubtle_0.6s_ease-in-out_infinite] motion-reduce:animate-none' />

For functional transitions, we paired motion-reduce:transition-none with the existing transition class. The Switch thumb, for example, still moves to the correct position; it just snaps there instead of sliding:

// Switch — functional, snap to final position
<span
  className={cn(
    'inline-block h-4 w-4 transform rounded-full bg-surface transition-transform motion-reduce:transition-none',
    checked ? 'translate-x-6' : 'translate-x-1'
  )}
/>

The translate-x still applies. The transition-transform is what gets removed. Position changes are instant, but the end state is identical.

Testing Motion Preferences

Each component that received motion-reduce: support got a corresponding test verifying the class is present:

it('applies motion-reduce:animate-none', () => {
  const { container } = render(<Spinner />);
  const spinner = container.firstChild;
  expect(spinner).toHaveClass('motion-reduce:animate-none');
});

This isn't testing browser behavior; it's testing that the class survives refactoring. A future contributor who removes the animation classes will see a failing test that makes the intent explicit.

The Takeaway

A global animation: none rule treats all motion the same. Component-level motion-reduce: utilities let us distinguish between motion that's decorative and motion that communicates state. Twelve components, twelve decisions, twelve lines of Tailwind. The audit is the work; the implementation is trivial.