Skip to content
Twenty One Media
webSeptember 4, 2026

The Reduced Motion Fix That Broke Hydration

We added reduced-motion support to the site's animation components during the homepage pass and shipped it. The review pass caught the problem: half the site was throwing "Hydration failed because the server rendered HTML didn't match the client."

Here is what happened and how we fixed it.

The straightforward approach that breaks SSR

The components use Framer Motion's useReducedMotion() hook. When a user has the prefers-reduced-motion media query set, the hook returns true and we swap the <motion.div> for a plain <div>:

function AnimatedReveal({ children, className }) {
  const reduce = useReducedMotion();
  if (reduce) return <div className={className}>{children}</div>;
  return <motion.div ...>{children}</motion.div>;
}

Clean, one-liner. Also wrong on a Next.js App Router page.

Why it fails

useReducedMotion() reads window.matchMedia("(prefers-reduced-motion: reduce)") on the client's first render. The server has no matchMedia. It returns null server-side, which coerces to false, so SSR always renders <motion.div>.

A user with reduced motion set gets a client that reads true on the very first render. React compares that <div> against the server's <motion.div>, finds a mismatch, and throws:

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client.

The full subtree re-renders from scratch on load. Every component in the tree takes a client-only path. The page works, but the error is real and the performance cost is real.

We caught this in dev with next start plus the browser's reduced-motion emulation toggle.

The fix: gate the swap until after hydration

The pattern is useSyncExternalStore from React. It accepts three arguments: a subscribe function, a client snapshot getter, and a server snapshot getter. When the server snapshot returns a different value than the client snapshot, React uses the server value through hydration and only switches to the client value after the tree is committed.

import { useSyncExternalStore } from "react";

const subscribe = () => () => {};

export function useHydrated() {
  return useSyncExternalStore(
    subscribe,
    () => true,  // client: we are hydrated
    () => false  // server: not yet
  );
}

The subscribe function is a no-op because we are not subscribing to any external store. We just need the two-snapshot contract.

In the component:

function usePlainMarkup(needsObserver = false) {
  const reduce = useReducedMotion();
  const hydrated = useHydrated();
  if (!hydrated) return false;   // always match SSR during hydration
  if (reduce) return true;
  return needsObserver && !("IntersectionObserver" in window);
}

While hydrated is false (server render and the hydration pass), usePlainMarkup returns false. Both server and client render <motion.div>. After hydration, React calls the client snapshot and gets true, hydrated flips, and the reduced-motion branch can safely return a different element.

The needsObserver check is there because Framer's viewport feature (whileInView) constructs an IntersectionObserver unguarded on mount. Older browsers that lack it would throw. Same fix applies: wait until hydrated, then check.

Where the same bug lives

Any component that branches on a browser-only API at render time has this problem. In our codebase, PageTransition wraps every <main> on every route and had the identical shape: it branched on useReducedMotion() and was regenerating the page body on every load for reduced-motion users.

The pattern to look for: a "use client" component that reads window, matchMedia, navigator, or any browser global and uses that value to choose which JSX tree to return. If the server can't produce the same value, the component needs useHydrated() to defer the switch.

The result

After the fix, both server and client agree on the markup through hydration. Reduced-motion users get a plain, no-animation page that loads correctly the first time. The error is gone, and the full subtree no longer regenerates on load.

A global CSS @media (prefers-reduced-motion) rule handles static properties. The hook handles component-level branches. They work together, not as alternatives.