DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Reduce Motion stops every CSS animation on our site and none of the JavaScript ones

Someone had been careful with motion preferences in CogniPrep's stylesheet. There are five prefers-reduced-motion blocks in app/globals.css, and they are not the one-line kind you paste in from a blog post.

There is the global brake:

/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  .stars-layer { animation: none !important; }

  [data-sonner-toast][data-mounted='true'],
  [data-sonner-toast][data-removed='true'] { animation: none !important; }
}
Enter fullscreen mode Exit fullscreen mode

There is a block that makes sure the fade-up elements, which start at opacity: 0 so they do not shift layout, are forced visible rather than left invisible forever. There are two no-preference blocks, so the will-change hints and the scroll-timeline reveal are only set up for people who want motion:

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal-on-scroll {
      animation: reveal-rise linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 70%;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And my favourite, which is the opposite of switching an animation off. One practice exercise has an element that shakes just before it bursts, and the shake is the only warning. Deleting the motion deletes the information, so the reduced version redefines the keyframe to pulse the opacity instead:

/* Shaking is the only cue that a bubble is about to burst, so keep a reduced
   version rather than removing the motion entirely. */
@media (prefers-reduced-motion: reduce) {
  @keyframes numbubble-shake {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.55; }
  }
}
Enter fullscreen mode Exit fullscreen mode

That is someone thinking about the actual user. So I turned the preference on, loaded the home page, and watched three dots bounce up and down forever.

None of it reaches framer-motion

Thirteen files in the repository import framer-motion. Zero import useReducedMotion. Zero use MotionConfig. The grep is the whole story:

$ grep -rl "framer-motion" app components | wc -l
13
$ grep -rn "useReducedMotion\|MotionConfig" app components lib | wc -l
0
Enter fullscreen mode Exit fullscreen mode

The reason that matters is the library's default, which surprised me more than our own omission. From framer-motion@12.42.2:

// dist/es/context/MotionConfigContext.mjs
const MotionConfigContext = createContext({
    transformPagePoint: (p) => p,
    isStatic: false,
    reducedMotion: "never",
});
Enter fullscreen mode Exit fullscreen mode

And the resolution:

const { reducedMotion } = useContext(MotionConfigContext);
if (reducedMotion === "never") {
    return false;
}
else if (reducedMotion === "always") {
    return true;
}
return reducedMotionPreference;
Enter fullscreen mode Exit fullscreen mode

"never" means never reduce. The library ships the media query listener and then, by default, declines to consult it. You opt in with <MotionConfig reducedMotion="user"> or by branching on useReducedMotion() yourself. We do neither, so every animate prop in the codebase runs at full amplitude no matter what the operating system says.

The global CSS brake cannot save it either, and this is the part worth internalising. That * rule governs animation-duration and transition-duration, which are CSS properties. A JavaScript animation library writes the value itself, frame by frame, into the inline style. There is no duration declaration for the stylesheet to override, so the !important lands on nothing.

What is actually still moving

Four animations in the repository repeat forever, and two of them are on the public home page. The typing indicator in the interview demo:

<motion.div
  key={i}
  className="h-2 w-2 rounded-full bg-white/70"
  animate={{ y: [0, -5, 0] }}
  transition={{ duration: 0.55, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }}
/>
Enter fullscreen mode Exit fullscreen mode

And a recording dot at duration: 1.2, plus a dash offset at 1.5 and a blurred halo scaling at 2 in the report demo. Infinite, translating, and completely unaware of the preference. For a product whose users are mostly about to sit a timed assessment, "I asked the system to calm down and the marketing page is still breathing at me" is not a good first impression.

Counting is useful here: the two biggest offenders by motion density are the feedback report demo (52 motion elements and props) and the interview demo (39). A per-component useReducedMotion() audit of those two files is most of the work.

While I was in the chunks

I checked whether the library even reaches the home page, because it is loaded through five next/dynamic calls that are commented "Lazy-load heavy components". The initial HTML has 18 async <script src> tags, and one of them is 135,739 bytes and contains framer-motion:

$ curl -s https://cogniprep.app/ | grep -o 'src="/_next/static/chunks/[^"]*"' | wc -l
18
Enter fullscreen mode Exit fullscreen mode

Pull that chunk and grep it for beforeChildren, a string only framer-motion has a use for, and it is there. So the dynamic import defers the demo components, not the animation runtime: 132 KB of it is in the initial download either way. The same chunk also contains the literal prefers-reduced-motion, which is the listener the default configuration never asks.

There is a note in one landing section about exactly this cost:

 * This used to be a Client Component whose only reason to be one was two
 * framer-motion `whileInView` fade-ups. That cost the landing page, the most
 * requested and most SEO-critical route in the app, twice over: the animation
 * library itself (~132 KB) in the initial bundle, and `lib/games/constants`
 * (~4,400 lines of game and provider metadata) shipped to the browser purely so
 * this markup could be rendered there instead of on the server.
Enter fullscreen mode Exit fullscreen mode

That section was converted to a Server Component and its reveal is now the CSS scroll timeline above, which is why the CSS half of this story is in such good shape. The JavaScript half was never revisited.

The fix, in order of value

One <MotionConfig reducedMotion="user"> around the client tree fixes the default for everything in one line, and framer-motion's own behaviour under it is sensible: it drops transform and layout animations and keeps opacity, which is the same judgement the hand-written keyframe above makes. Then the four infinite loops want a deliberate decision each, because repeat: Infinity is the one thing people with vestibular disorders report as actively unpleasant. None of that is deployed yet; this post is the audit, not the patch.

See it

Open DevTools on cogniprep.app, go to the Rendering panel, and set "Emulate CSS media feature prefers-reduced-motion" to reduce. Reload. The scroll reveals stop, the toast animations stop, and the demo panels further down the page keep animating exactly as before. Then try the same on the practice catalogue, which is server-rendered and has no JavaScript animation on it at all: there, the setting does everything you would expect.

Top comments (0)