Your animations are fine. Your prefers-reduced-motion handling probably isn't.
Here's a trap I fell into for years: I did add a reduced-motion block. It was right there at the bottom of my global CSS, three lines long, and I felt good about it. It looked like this:
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
This passes an automated a11y audit. It also breaks your product for the exact people it was written for.
Why the nuclear option backfires
animation: none doesn't "reduce" anything. It deletes the motion — including the motion that carries meaning. Kill that and you get:
- Drawers and modals that teleport. A slide-in panel that used to take 240ms now appears instantly in its final position. There's no longer any spatial relationship between where it was and where it went.
- Focus loss on scroll-triggered content. A section that faded in on scroll now just doesn't render its transition state — and depending on how you built it, sometimes doesn't render at all.
- Skeletons that lie. A pulsing loading skeleton frozen mid-opacity can read as "finished, empty" instead of "still loading."
Reduced motion is not "no motion." It's less vestibular-triggering motion. The WCAG target is animation that doesn't involve large-scale movement, parallax, or scaling — not the absence of all feedback.
What I do instead
The rule I've landed on: replace, don't remove. Four patterns cover almost everything.
1. Swap a transform for a cross-fade. Movement is the trigger, opacity isn't. So a slide becomes a fade at the same duration.
.panel {
transition: transform 240ms ease, opacity 240ms ease;
}
@media (prefers-reduced-motion: reduce) {
.panel { transform: none !important; transition: opacity 240ms linear; }
}
2. Keep the easing, cap the travel. A 16° tilt or a 400px parallax is the problem. 2° of tilt still reads as "interactive" and triggers nothing.
3. Animate a non-spatial property. Progress is the useful half of a loading animation. Drive it with width, a background gradient position, or a text counter instead of a spinning element.
4. Keep state changes, shorten them. A 40ms opacity flip is not a vestibular trigger. It's still enough to tell the user "this changed because of you."
And the part people forget: reduced motion is also about scroll and pointer. Smooth-scroll libraries, scroll-jacking, magnetic cursor effects, and auto-playing background loops all need the same treatment as your CSS transitions. Your <html> scroll-behavior counts.
The testing gap
Here's the honest part: most of this passes automated checks. A script can verify that a reduced-motion media query exists. It cannot tell you whether your modal now teleports.
The only way I've found to actually catch it:
- Toggle the OS setting for real — macOS Reduce motion, Windows Show animations off, or Firefox's
ui.prefersReducedMotionflag if you want to avoid changing your desktop. - Walk your five most important flows with it on. Open the modal. Submit the form. Scroll the pricing page.
- Ask one question per interaction: can I still tell what happened?
If the answer is no, you didn't reduce the motion. You removed the information.
Receipts over vibes
The reason I care about this beyond principle: motion components are the ones people paste in blind. You grab a tilt card or an aurora background, it looks great, and nobody checks what it does under reduced-motion until an audit or a complaint.
That's a fixable information problem. Every animated asset on Motif UI ships with an accessibility score next to its bundle size and dependency count — the library is nothing but original components, all MIT, and the a11y number is part of the audit gate an item has to clear before it gets a card at all. Tilt Card is 2.9 KB with zero deps; Aurora Veil is pure CSS and runs at 60fps with 0 JS. Those numbers are on the page, which is the point — you can check them instead of trusting a screenshot.
Six lines of CSS is not accessibility. Knowing what those six lines do to your users is.
Top comments (0)