Skip to content

03Engineering

4 min read

By Wyatt McPherson

The reduced-motion bug that hid an entire website

A four-line accessibility guard blanked every page for the people it was written to protect. Here is why React could not fix it, and the shape of the mistake.

  • React
  • Next.js
  • Accessibility
  • Hydration

In short

Why does a framer-motion component pinned at opacity 0 never animate for users with reduced motion enabled?

Because branching on useReducedMotion() changes which element the component returns, and that value differs between the server and the client. The server renders the motion element with its opening inline style of opacity:0; a reduced-motion client renders a plain element with no style. React does not patch mismatched attributes during hydration, so the server's opacity:0 survives, and because no motion component is mounted nothing ever animates it away. The fix is to keep the element type identical in both renders and express the preference through MotionConfig instead.

01

The component looked correct

Entrance animations on this site are handled by one component. It fades an element in and lifts it fourteen pixels, once, when it scrolls into view. The first version had what looked like a careful accessibility guard: if the visitor has asked their operating system to reduce motion, skip the animation entirely and render a plain element.

That guard is the bug. It repays a close look, because the reasoning behind it is good and the outcome is the worst one available.

The version that blanked the sitetsx
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

Three facts that combine badly

First: useReducedMotion() reads a media query. On the server there is no media query to read, so it returns null. Every server render therefore takes the animated branch and emits the motion element with its opening state written inline: style="opacity:0; transform:translateY(14px)".

Second: on a client whose operating system has reduce-motion enabled, the hook returns true on the very first render. That render returns a plain element with no style attribute at all. The server tree and the client tree now disagree about the attributes of the same node.

Third: React does not repair mismatched attributes during hydration. It reconciles the tree, logs that some attributes of the server-rendered HTML did not match, and explicitly says it will not patch them up. That is the step that turns a warning into a blank page. The inline opacity:0 stays on the element, and because the plain branch mounted no motion component, there is nothing left in the page that would ever animate it to 1.

03

What it measured

Rendered against a production build in headless Chrome with prefers-reduced-motion set to reduce, then scrolled to the bottom of every route, the count of elements left at a computed opacity of zero was: 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, the count was zero everywhere.

Visually, a reduced-motion visitor got a header, a footer, and an empty cream page between them. On every route. The site was, for them, completely unusable — and the component's own comment claimed it existed so that "reduced-motion users get the final state immediately".

04

The fix is to stop branching

The rule is narrow and absolute: never let a client-only value 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 attribute mismatches are unrepairable by design.

The preference still has to be honoured, so it moves somewhere that cannot change the tree. framer-motion's MotionConfig takes a reducedMotion prop; setting it to "user" at the root suppresses transform animation while still resolving opacity, so content appears without moving. The component itself now always renders the motion element.

Then, because the opening state is written inline by JavaScript, there is a second failure mode to close: if the runtime never loads at all, 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 regardless of whether any JavaScript runs.

The guarantee that does not depend on the runtimecss
@media (prefers-reduced-motion: reduce) {
  .cs-reveal {
    opacity: 1 !important;
    transform: none !important;
  }
}

05

How it was caught, and how it should have been

It was caught by a review pass that rendered every route with the media feature emulated and counted elements whose computed opacity was below one. That check now runs as part of the visual audit for this site, alongside a horizontal-overflow measurement and a contrast sweep.

The general version is cheap and worth stealing: emulate prefers-reduced-motion, scroll the page, then assert that nothing is still invisible. It takes about fifteen lines. No unit test will catch this class of animation bug, because the failure lives in the difference between two renders and neither render is wrong on its own.