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:
| Component | Animation | Verdict |
|---|---|---|
| Spinner | animate-spin | Decorative |
| Loading | bouncing dots | Decorative |
| Skeleton | animate-pulse | Decorative |
| ProgressBar | indeterminate fill | Decorative |
Functional transitions communicate a state change, so killing them means giving the element a static alternative that still tells the user what happened:
| Component | Animation | Verdict |
|---|---|---|
| Switch | thumb slide | Functional (position snap) |
| Sidebar | panel slide | Functional (instant show) |
| Toast | entry/exit slide | Functional (instant show) |
| Dropdown | menu slide-down | Functional (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.
