Skip to main content

Systematic prefers-reduced-motion Across a Component Library

A slow-motion water droplet frozen in time
Apr 8, 20262 min readTailwind CSS, Design Systems, CSS

The audit

Of the 30+ components in shared-ui, twelve animated something and not one of them checked prefers-reduced-motion first. The offenders were Spinner, Loading, ProgressBar, Switch, Sidebar, ThemeToggle, Button, Skeleton, Dropdown, Toast, Modal, and Tooltip.

A global CSS rule like @media (prefers-reduced-motion: reduce) { * { animation: none !important; } } would have been a one-line fix, and it's exactly the kind of fix I'd reach for at 5pm on a Friday. It's also a blunt instrument: it kills every animation indiscriminately, including the transitions that tell you a state actually changed. I wanted to decide component by component instead.

Decorative vs. functional

The audit forced me to put every animation in the library into one of two buckets.

Decorative animations are there for polish, and pulling them changes nothing about how someone reads the interface:

ComponentAnimationVerdict
Spinneranimate-spinDecorative
Loadingbouncing dotsDecorative
Skeletonanimate-pulseDecorative
ProgressBarindeterminate fillDecorative

Functional transitions communicate a state change, so killing them means giving the element a static alternative that still tells the user 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 enough. 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 straight onto @media (prefers-reduced-motion: reduce), so I applied it right at the component level, next to the animation it's modifying:

// 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, I paired motion-reduce:transition-none with the existing transition class. The Switch thumb still lands in the right spot; 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; it's the transition-transform that gets removed, so the position change is instant and the end state is identical.

Testing motion preferences

Every component that picked up motion-reduce: support got a matching test that checks the class is actually 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 a refactor. A future contributor who strips the animation classes gets a failing test that spells out the intent for them.

The takeaway

A global animation: none rule treats all motion the same, while component-level motion-reduce: utilities let me separate the motion that's pure decoration from the motion that's carrying meaning. Twelve components, twelve decisions, twelve lines of Tailwind. The audit was the actual work; once that was done the code was the easy part.