The reduced-motion bug that hid an entire website
A four-line accessibility guard blanked every page for the people it was meant to help, and React couldn’t repair it during hydration.
- React
- Next.js
- Accessibility
- Hydration
Why did our site go blank for people who turn animations off?
My fade-in component showed a plain element to anyone whose device was set to reduce motion, and an animated one to everyone else. The server can’t see that setting, so it always sent the animated version, which starts invisible. React doesn’t correct a mismatched style when the page loads, so the invisible style stayed and nothing was left to fade the content in. The fix was to always render the same element and handle the setting with framer-motion’s MotionConfig.
01
The fade-in component
Every entrance animation on this site goes through one component. It fades an element in and lifts it fourteen pixels, once, when it scrolls into view. The first version had what I thought was a careful accessibility guard: if the visitor has asked their operating system to reduce motion, skip the animation and render a plain element.
That guard was the bug, even though the idea behind it was a good one.
const reduced = useReducedMotion();
if (reduced) {
const Tag = as;
return <Tag className={className}>{children}</Tag>;
}
return (
<MotionTag
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
{children}
</MotionTag>
);02
Why the page went blank
useReducedMotion() reads a media query. On the server there’s no media query to read, so it returns null. That meant every server render took the animated branch and sent the motion element with its opening state written inline: style="opacity:0; transform:translateY(14px)".
In a browser with reduce motion turned on, the hook returns true on the very first render. That render returns a plain element with no style attribute at all, so the server and the browser disagree about the attributes of the same node.
React doesn’t repair mismatched attributes during hydration. It reconciles the tree, logs that some attributes of the server-rendered HTML didn’t match, and says it won’t patch them up. That’s why a console warning became a blank page. The inline opacity:0 stays on the element, and because the plain branch never mounted a motion component, nothing on the page was ever going to animate it to 1.
03
How bad it was
On a production build in headless Chrome, with prefers-reduced-motion set to reduce and every route scrolled to the bottom, this many elements were still at a computed opacity of zero: 29 on the home page, 39 on the work index, 41 on each case study, 58 on the services page, 19 on contact, and 6 on the 404. With no preference set, it was zero everywhere.
So anyone with reduced motion turned on got a header, a footer and an empty cream page in between, on every route. The site was unusable for them, and the component’s own comment said it was there so that "reduced-motion users get the final state immediately".
04
The fix
The component can’t branch on that setting any more. A value only the browser knows should never decide which element a component returns, or whether a style-bearing wrapper is there at all. Anything that changes the shape of the tree between the server render and the first client render is a hydration mismatch, and React doesn’t repair attribute mismatches, by design.
The preference still has to be respected, so it moved somewhere that can’t change the tree. framer-motion’s MotionConfig takes a reducedMotion prop. Set to "user" at the root, it turns off transform animation but still resolves opacity, so content appears without moving. The component now always renders the motion element.
That left one gap. The opening state is written inline by JavaScript, so if the runtime never loads, nothing clears it. A CSS rule under the reduced-motion media query, and a copy inside a noscript block, pin the element to its final state whether or not any JavaScript runs.
@media (prefers-reduced-motion: reduce) {
.cs-reveal {
opacity: 1 !important;
transform: none !important;
}
}05
How it was found
A review pass rendered every route with the media feature emulated and counted elements whose computed opacity was below one. That check now runs in this site’s visual audit, along with a horizontal overflow measurement and a contrast sweep.
You can run the same check on your own site: turn on prefers-reduced-motion, scroll the page, then check that nothing is still invisible. It’s about fifteen lines. A unit test won’t catch this, because each render is fine on its own.
Continue