DEV Community

Cover image for The Bug That Kept Coming Back in Framer Motion
Carlos José Castro Galante
Carlos José Castro Galante

Posted on

The Bug That Kept Coming Back in Framer Motion

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

Entiscore is an agent that audits a website's digital entity, checking schema markup, identity consistency, authority signals, and technical accessibility before returning a scored report. Somewhere along the way I decided the report needed some animation to it, scroll-triggered entrance animations built with Framer Motion's whileInView, a blur-to-focus reveal for cards, and staggered timing for lists.

Symptoms

The first time it happened, the Hero title on the landing page got stuck mid-animation, permanently blurred, permanently offset, frozen in its hidden state with no way to recover. A hard refresh fixed it but scrolling did nothing, and it just sat there broken until the page reloaded.

I fixed it, or so I thought.

A few days later a different section broke the exact same way, with four feature cards on the homepage stuck in a half-rendered blur that never resolved to their final state. Same symptom, different component. I fixed that one too and moved on.

Then it happened a third time, in the actual product report, in the "Evaluation by axis" section, which is the part of the UI a judge would actually look at during a demo. Three different components, three apparent bugs, the exact same failure mode.

The investigation

Each individual fix had worked in isolation, which was the trap. I kept treating the symptom as local, this specific card's animation is broken so I'd rewrite its transition and move on, and that approach papers over the actual defect instead of finding it.

The question I should have asked the first time was what these three components had in common. The answer was a shared helper that looked like this:

function getVariants(motionSafe: boolean, base: Variants): Variants {
  if (!motionSafe) {
    return { hidden: { opacity: 1 }, visible: { opacity: 1 } };
  }
  return base;
}
Enter fullscreen mode Exit fullscreen mode

Called inline, inside the component body, on every single render:

<motion.div
  variants={getVariants(motionSafe, blurReveal)}
  initial="hidden"
  whileInView="visible"
  viewport={{ once: true }}
>
Enter fullscreen mode Exit fullscreen mode

Root cause

getVariants returns a brand-new object reference every time it runs, even when the underlying values are identical. Framer Motion tracks the variants prop by reference rather than by deep equality, so on every re-render it received what looked like a completely new set of animation variants.

Combined with viewport={{ once: true }}, that created a serious problem. The transition to "visible" needs to be triggered by the intersection observer callback, but if a re-render swaps out the variants object mid-transition or right as the observer fires, the animation state and the variants object fall out of sync. The component ends up holding a visible state that points to a variants object that no longer matches what's actually being interpolated, and because once: true means the trigger only fires a single time, there's no second chance to self-correct.

The Hero title, the feature cards, and the axis evaluation cards weren't three separate bugs but the same defect hit three separate times, because the anti-pattern lived in one function and got reused everywhere Framer Motion needed a reduced-motion fallback.

The fix

Once I stopped treating the problem as three isolated incidents and searched the entire codebase for every call site of getVariants and its sibling getStaggerVariants, the fix itself was straightforward:

const CARD_VARIANTS: Variants = {
  hidden: { opacity: 0, filter: "blur(4px)", y: 16 },
  visible: { opacity: 1, filter: "blur(0px)", y: 0 },
};

const REDUCED_MOTION_VARIANTS: Variants = {
  hidden: { opacity: 1 },
  visible: { opacity: 1 },
};

<motion.div
  variants={motionSafe ? CARD_VARIANTS : REDUCED_MOTION_VARIANTS}
  initial="hidden"
  whileInView="visible"
  viewport={{ once: true }}
>
Enter fullscreen mode Exit fullscreen mode

Constants defined once, outside the render path, with a simple ternary instead of a function call. The object reference is now stable across renders so Framer Motion's internal tracking never gets confused about which variants it's interpolating toward. Six files had the same pattern and all six got fixed in a single pass.

Before and after

Before the fix, cards and titles occasionally rendered permanently blurred and never recovered without a full page reload, reproducible but not consistently, which made it easy to treat each occurrence as unrelated to the others.

After the fix, every scroll-triggered reveal in the app resolves correctly, every time, across every entry point I could find.

What I learned

Animation libraries that key transitions off object identity are unforgiving of helper functions that build config objects inline. If you're writing a function that returns a Variants object, a style object, or anything else React or a library will diff by reference, and you're calling that function during render, the reference changes on every render even when the values don't. Hoisting it to a constant outside the component lets the render function pick between two stable references instead of generating a new object every time.

The other thing that came out of this was more about process than code. The third occurrence was the only one I actually investigated properly, and it was the only one that produced a fix that stuck, because it was the first time I looked for what the three broken components had in common instead of patching the one in front of me.


Built as part of Entiscore, an entity-audit agent built for the Kiro powered by AWS hackathon by Código Facilito.

Top comments (0)