Entiscore uses Framer Motion for scroll-triggered entrance animations: titles that reveal with a blur-to-focus effect when they enter the viewport, cards that appear in staggered sequence within a section. The initial implementation handled the reduced motion case through a helper function that decided, based on a boolean flag, which set of variants to return.
function getVariants(motionSafe: boolean, base: Variants): Variants {
if (!motionSafe) {
return { hidden: { opacity: 1 }, visible: { opacity: 1 } };
}
return base;
}
This function was called directly inside the render of every component that needed an entrance animation.
<motion.h1
variants={getVariants(motionSafe, heroTextReveal)}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
Entiscore
</motion.h1>
How the symptoms appeared
The first symptom appeared on the Hero title of the main page. The text got stuck visually midway through the entrance animation, with blur and vertical offset applied permanently, never reaching its final sharp state. A full page reload fixed it momentarily, but scrolling, waiting, or interacting with the rest of the interface had no effect. The element stayed frozen in that intermediate state until the next refresh.
This first case had an additional cause layered on top of the one that would eventually be identified as the shared root. The initial diagnosis found a hydration mismatch between server and client, caused by evaluating prefers-reduced-motion with window.matchMedia directly during render instead of after component mount. Since the server has no access to that browser API, the HTML it generated on the first render didn't match what the client produced during hydration, and React reported the corresponding hydration error. That cause was fixed by moving the motionSafe detection into state updated inside an effect after mount, ensuring the first client render matches the server render. That correction resolved the console hydration error, but the title kept getting stuck with blur anyway, which led to further investigation and eventually to the shared root cause.
That first case was fixed in isolation, adjusting the Hero component without investigating further. A few days later, the exact same symptom appeared in a different component: four cards inside the explanatory section of the main page, each supposed to reveal with the same blur-to-focus effect on scroll. It was treated, again, as an isolated problem specific to that component, and fixed the same localized way.
The third occurrence happened in a component that mattered for a public product demo: the axis evaluation section inside the generated analysis report, the part of the interface a user or evaluator would look at most closely. At that point the pattern became impossible to ignore. Three distinct components, without direct relationship to each other in the component tree, showing exactly the same broken behavior. That identical repetition was the signal that this wasn't three independent bugs but a single defect shared by a piece of code common to all three.
Why Framer Motion breaks with dynamic object references
Framer Motion determines whether to start a transition by comparing the variants prop by reference identity, not by deep equality of its contents. Two objects with the same keys and the same values but occupying different memory addresses are treated as two different animation configurations.
getVariants builds and returns a new object literal every time it executes, even when motionSafe and base haven't changed between one call and the next. Since that function was invoked directly inside the render, every render generated a different reference for the variants prop, even though the logical content of the animation was identical to the previous render.
The problem compounds with viewport={{ once: true }}. This configuration tells Framer Motion to trigger the transition to the visible state exactly once, when the element first enters the viewport, and to ignore any subsequent intersection observer firing. If a component re-render happens at the same moment the observer fires that transition, or immediately after, the variants object Framer Motion was using to interpolate toward visible is no longer the same object the component passes on the next render. The library ends up holding a reference to a set of variants that no longer matches what the component considers current, and since the single trigger has already been consumed, there's no second opportunity for the transition to resolve correctly. The observable result is an element frozen in the hidden state, blur included, with no recovery path short of a full component remount.
Finding the full extent of the problem
Once it was clear the problem lived in the shared function and not in any of the three components where it manifested, the entire project was reviewed for any other point calling getVariants or its staggered list equivalent getStaggerVariants inside a render body. Both functions lived in a shared file called motion.ts, and calls to these functions appeared both directly inside page components and through two reusable wrapper components designed specifically to handle scroll animations, ScrollReveal.tsx and StaggerReveal.tsx, which called those functions internally.
Six files had the same pattern in total: the three already identified by their symptoms and three more that hadn't shown the problem visibly yet but contained exactly the same latent failure condition.
The affected variants weren't all the same animation repeated. They included fadeInScale, staggerContainer, blurReveal, fadeInUp, slideInFromLeft, slideInFromRight, staggerContainerSlow, cardReveal, and listItemReveal. That the defect appeared identically across configurations with such different names and purposes confirms the problem was structural, rooted in how the generator functions were called, not a coincidence between similar cases.
The fix
The correction replaced the generator functions with Variants constants defined outside the component, at the module's top level, so their reference stays stable across all renders within the component's lifetime.
const HERO_TITLE_VARIANTS: Variants = {
hidden: { opacity: 0, filter: "blur(4px)", y: 30 },
visible: { opacity: 1, filter: "blur(0px)", y: 0 },
};
const REDUCED_MOTION_VARIANTS: Variants = {
hidden: { opacity: 1 },
visible: { opacity: 1 },
};
<motion.h1
variants={motionSafe ? HERO_TITLE_VARIANTS : REDUCED_MOTION_VARIANTS}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
Entiscore
</motion.h1>
The conceptual shift is replacing a function that manufactures a configuration object on each execution with a selection between two already-existing and stable references. The render still decides which one to use based on motionSafe, but it never creates a new object to make that decision, so Framer Motion always receives the same reference while conditions don't change, and the transition fires once and completes without ambiguity about which configuration it was interpolating.
All six files were fixed with the same pattern in a single pass, rather than waiting for each one to manifest the symptom at some future point.
What the diagnosis process revealed
The most relevant part of this case isn't the fix itself, which comes down to moving an object construction outside the render. It's the decision that was finally made on the third occurrence.
The first two times, the natural reaction under time pressure was to fix the component showing the problem and keep moving, without asking whether the same condition could appear somewhere else in the codebase. That reaction is understandable, and in many cases sufficient. It stops being sufficient the moment an identical symptom reappears in a component with no apparent relationship to the previous one.
Treating the third recurrence as a signal to audit the entire codebase for the same pattern, rather than fixing a third isolated symptom, is what made it possible to resolve all three known cases alongside three more that hadn't surfaced yet in a single pass.
The transferable principle isn't specific to Framer Motion. Any library that determines its behavior by comparing configuration objects by reference rather than by content will produce the same type of failure if those objects are constructed inside the render instead of defined as stable constants outside it. The pattern applies to any imperative API that caches or diffs configuration by reference, which covers a wider surface than animation libraries alone.
One additional detail that surfaced during this audit: Framer Motion from version 12 onwards respects prefers-reduced-motion natively at the animation engine level, without the project needing to maintain its own detection logic and alternative variants for that case. The project kept its manual implementation as it was, but for anyone evaluating how much custom logic to maintain around motion accessibility in a new project, it's worth checking what the library version already handles before rebuilding that logic manually.
Entiscore is available at entiscore.vercel.app. Built with Next.js, TypeScript, Supabase and Claude API for the Kiro powered by AWS hackathon by Código Facilito.

