prefers-reduced-motion is a correctness requirement, not a nicety
prefers-reduced-motion is a correctness path, not polish. We shipped an h1 stuck at zero opacity because the reduced-motion branch passed initial={undefined} to Framer Motion and inherited a hidden variant with nothing to animate it out. Nobody reviewing had the setting on. Treat that failure like a broken build.
One undefined, one invisible headline
The animation was a staggered word reveal. Each word starts translated down and transparent, then animates into place. In framer-motion terms: a hidden variant with zero opacity, a visible variant with full opacity, orchestrated by a parent.
The reduced-motion branch was supposed to skip all of that. What it did was pass initial={undefined}. The author's mental model was that undefined means no initial state, so the element just renders normally. What actually happens is that the prop falls back to the variant chain — the parent's hidden variant, inherited down — while the animate side was also disabled. So the words resolved to hidden and had nothing to move them out of it.
The lesson underneath is about how you disable an animation. Passing undefined asks the library to decide, and libraries decide using rules you have not fully internalized. The reliable version is to state the end state explicitly: initial and animate both set to visible, so the element is already where it should finish and no transition is required to get it there.
Nobody on your team has reduced motion on
This is the structural reason the bug shipped. Reduced motion is a system setting, most developers do not use it, and it is not something you stumble into. The branch gets written once, tested once if you are diligent, and then every subsequent change to that component is reviewed exclusively through the animated path.
It is worse than a rarely-used feature, because it is a parallel rendering path through code you are actively changing. Every refactor of the animation has a chance to break the branch that does not animate, and nothing in the normal development loop will tell you.
The fix is process, not code. Turn it on. On macOS it is Accessibility, Display, Reduce motion. Chrome DevTools has an emulation toggle in the rendering panel. If you are touching animation code, flip it before you open the pull request. It takes seconds and it is the only reliable check.
/* Move position, never visibility. If this animation never runs --
a paused background tab, a crawler, no JS -- the worst case is a
card sitting 18px low and completely readable. An opacity fade
would leave it invisible. */
@keyframes noteRise {
from { transform: translateY(18px); }
to { transform: translateY(0); }
}Reduced motion does not mean no animation
The setting exists for vestibular disorders and motion sensitivity. Large positional movement, parallax, scaling, spinning — that is what causes problems. Opacity generally does not. The guidance is to reduce motion, not to strip every transition, and a page that goes completely static can read as broken, because affordances that relied on movement to communicate state stop communicating anything.
So our default is to keep opacity transitions and drop transforms. A word reveal becomes a fade with no translation. A card that lifted on hover changes its shadow instead. The interaction still reads. The movement is gone.
The structural detail that matters most: the reduced-motion version should be a different set of animation values, not a different code path. Same component, same variants, different numbers. One path means one thing to test and no opportunity for the two branches to diverge as the component changes.
The containing-block trap
Related, and worth knowing before you write a page transition. We wanted a shutter effect between routes, and the natural implementation is to animate a wrapper around the page content — translate it, or scale it, as the route changes.
The moment you put a transform on an element, that element becomes the containing block for every position: fixed descendant inside it. Fixed elements stop being positioned relative to the viewport and start being positioned relative to the wrapper, which means a fixed header, a sticky nav, a modal overlay, and a cookie banner all silently become absolutely positioned. Nothing errors. Things are just in the wrong place, and only during the transition, which makes it maddening to reproduce. The same applies to filter, backdrop-filter, perspective, and will-change on transform.
We ended up with opacity-only page transitions for exactly this reason. Opacity does not create a containing block. It is a smaller effect than we originally designed, and it does not quietly break every fixed element on the site, which turned out to be the more valuable property.
Treating it as correctness
The framing that changed how we work: a reduced-motion bug is not an accessibility nice-to-have that did not get done. It is a subset of users seeing a broken page. In our case it was the largest text on the page, missing.
Which puts it in the same category as a broken build — something that blocks a release, not something that goes into a backlog labelled polish. The check costs thirty seconds. The failure is total for the people it affects.
And they are not a rounding error. Motion sensitivity is common, the setting is on by default in several accessibility configurations, and people who turn it on tend to leave it on across every site they visit. You will never hear from them. They will just leave.
What we changed in review
The process change is deliberately small, so that it survives. Any pull request touching animation carries one line: checked with reduced motion on. Not a screenshot requirement, not a checklist of twelve items. One claim somebody has to be willing to make.
It is a claim rather than an automated check because we have not found an automated check worth its maintenance. You can assert that an element's computed opacity is not zero after mount under an emulated media query, and it will pass happily while the element sits behind a transform, or off-screen, or at the wrong size. The failure mode of a partial automated check here is false confidence, which is worse than the honest version where a person looked.
The counterpoint we grant: this depends on people telling the truth on a line nobody verifies, which is exactly the kind of process that decays. What keeps it alive is that the check is genuinely fast. A process that costs thirty seconds gets done. One that costs ten minutes gets skipped, and then quietly removed from the template six months later.
Questions
- What is a safe way to disable Framer Motion for reduced motion?
- Do not pass undefined and hope the library skips initial state. Explicitly set the end visual state — full opacity, no transform — so the element cannot inherit a hidden parent variant with animate disabled.
- Does reduced motion mean remove every transition?
- No. The setting targets large positional motion that triggers vestibular issues. Keeping opacity fades while dropping transforms is usually enough for affordances to still read without the movement.
- Why can page-transition transforms break fixed headers?
- A CSS transform on an ancestor creates a containing block for position:fixed descendants. Fixed navs, modals, and banners suddenly position relative to the animating wrapper. Opacity-only transitions avoid that trap.