Entiscore usa Framer Motion para las animaciones de entrada: títulos que se revelan con un efecto de blur a foco al hacer scroll, tarjetas que aparecen de forma escalonada dentro de una sección. La implementación inicial manejaba el caso de reduced motion con una función auxiliar que decidía, según una bandera booleana, qué conjunto de variants devolver.

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

Esta función se llamaba directamente dentro del render de cada componente que necesitaba una animación de entrada.

<motion.h1
  variants={getVariants(motionSafe, heroTextReveal)}
  initial="hidden"
  whileInView="visible"
  viewport={{ once: true }}
>
  Entiscore
</motion.h1>

Cómo apareció el síntoma

El primer síntoma apareció en el título del Hero de la página principal. El texto quedaba visualmente trabado a mitad de la animación de entrada, con blur y desplazamiento aplicados de forma permanente, sin llegar nunca a su estado final nítido. Un recargado completo de la página lo resolvía momentáneamente, pero scrollear, esperar o interactuar con el resto de la interfaz no tenía ningún efecto.

Este primer caso tuvo una causa adicional superpuesta a la que se identificaría después como la raíz compartida. El diagnóstico inicial encontró un mismatch de hidratación entre servidor y cliente, provocado por evaluar prefers-reduced-motion con window.matchMedia directamente durante el render en vez de después del montaje del componente. Como el servidor no tiene acceso a esa API del navegador, el HTML que generaba en el primer render no coincidía con el que el cliente producía al hidratar. Esa causa se corrigió moviendo la detección de motionSafe a un estado que se actualiza dentro de un efecto posterior al montaje, garantizando que el primer render en cliente coincida con el del servidor. Esa corrección resolvió el error de hidratación en consola, pero el título siguió quedando trabado con blur de todas formas, lo que llevó a seguir investigando hasta encontrar la causa.

Ese primer caso se corrigió de forma puntual, ajustando la lógica del componente del Hero sin ir más allá de ese archivo. Días después, el mismo síntoma exacto apareció en un componente distinto, un conjunto de cuatro tarjetas dentro de la sección explicativa de la página principal, cada una destinada a revelarse con el mismo efecto de blur a foco al entrar en el viewport. Se trató, otra vez, como un problema aislado de ese componente puntual.

La tercera aparición ocurrió en la sección de evaluación por eje dentro del reporte generado por el análisis, la parte de la interfaz que un usuario o evaluador vería con más atención. Tres componentes distintos, sin relación directa entre sí en el árbol de la aplicación, mostrando exactamente el mismo comportamiento roto. Esa repetición idéntica fue la señal de que no se trataba de tres bugs independientes sino de un único defecto compartido por una pieza de código común a los tres.

Por qué Framer Motion falla con objetos dinámicos

Framer Motion determina si debe iniciar una transición comparando el prop variants por identidad de referencia, no por igualdad profunda de su contenido. Dos objetos con las mismas claves y los mismos valores, pero que ocupan direcciones de memoria distintas, son tratados como dos configuraciones de animación diferentes.

getVariants construye y retorna un objeto literal nuevo cada vez que se ejecuta, incluso cuando motionSafe y base no cambiaron entre una llamada y la siguiente. Como esa función se invocaba directamente dentro del render, cada render del componente generaba una referencia distinta para el prop variants, aunque el contenido lógico de la animación fuera idéntico al del render anterior.

El problema se agrava con viewport={{ once: true }}. Esta configuración le indica a Framer Motion que dispare la transición hacia el estado visible una única vez, cuando el elemento entra por primera vez en el viewport, ignorando cualquier disparo posterior del intersection observer. Si un re-render ocurre en el mismo instante en que el observer dispara esa transición, el objeto de variants que Framer Motion estaba usando para interpolar hacia visible deja de ser el mismo objeto que el componente le pasa en el siguiente render. La librería queda sosteniendo una referencia a un conjunto de variants que ya no coincide con el que el componente considera vigente, y como el disparo único ya se consumió, no hay segunda oportunidad para que la transición se resuelva correctamente. El resultado es un elemento congelado en el estado hidden, blur incluido, sin ningún camino de recuperación salvo un remount completo del componente.

Encontrar el alcance completo del problema

Una vez identificado que el problema vivía en la función compartida y no en ninguno de los tres componentes donde se manifestó, se revisó el proyecto completo en busca de cualquier otro punto que llamara a getVariants o a su equivalente para listas escalonadas, getStaggerVariants, dentro del cuerpo de un render. Ambas funciones vivían en un archivo compartido llamado motion.ts, y sus invocaciones aparecían tanto directamente dentro de componentes de página como a través de dos componentes reutilizables, ScrollReveal.tsx y StaggerReveal.tsx, que a su vez llamaban a esas funciones internamente.

Se encontraron seis archivos con el mismo patrón en total, los tres ya identificados por sus síntomas y otros tres que todavía no habían mostrado el problema de forma visible pero contenían exactamente la misma condición de falla latente.

Las variants afectadas no eran todas la misma animación repetida. Incluían fadeInScale, staggerContainer, blurReveal, fadeInUp, slideInFromLeft, slideInFromRight, staggerContainerSlow, cardReveal y listItemReveal. Que el defecto se repitiera de forma idéntica a través de configuraciones con nombres y propósitos tan distintos confirma que el problema era estructural, propio de cómo se invocaban las funciones generadoras, y no una coincidencia entre casos parecidos.

La corrección

La corrección reemplazó las funciones generadoras por constantes de tipo Variants definidas fuera del componente, en el nivel superior del módulo, de forma que su referencia permanece estable a lo largo de todos los renders del ciclo de vida del componente.

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>

El cambio conceptual es reemplazar una función que fabrica un objeto de configuración en cada ejecución por una selección entre dos referencias ya existentes y estables. El render sigue decidiendo cuál de las dos usar según motionSafe, pero nunca crea un objeto nuevo para tomar esa decisión, así que Framer Motion siempre recibe la misma referencia mientras las condiciones no cambien.

Los seis archivos identificados se corrigieron con el mismo patrón en una sola revisión, en vez de esperar a que cada uno manifestara el síntoma por separado.

Lo que reveló el proceso de diagnóstico

La parte más relevante de este caso no es la corrección en sí, que se reduce a mover una construcción de objeto fuera del render. Es la decisión que se tomó recién en la tercera aparición del mismo síntoma.

Las primeras dos veces, la reacción natural bajo presión de tiempo fue corregir el componente que tenía el problema delante y seguir avanzando, sin preguntarse si esa misma condición podía repetirse en otro lugar del código. Esa reacción es comprensible, y en muchos casos suficiente, pero deja de serlo cuando un síntoma idéntico reaparece en un componente sin relación aparente con el anterior.

Tratar la tercera repetición como una señal para auditar todo el código base en busca del mismo patrón, en vez de corregir un tercer síntoma aislado, es lo que permitió encontrar y resolver de una sola vez los tres casos ya conocidos junto con otros tres que todavía no se habían manifestado.

El principio transferible no es específico de Framer Motion. Cualquier librería que determine su comportamiento comparando objetos de configuración por referencia en vez de por contenido va a exhibir el mismo tipo de falla si esos objetos se construyen dentro del render en vez de definirse como constantes estables fuera de él.

Un dato adicional que surgió durante esta auditoría: Framer Motion a partir de su versión 12 respeta prefers-reduced-motion de forma nativa a nivel de motor de animación, sin que el proyecto necesite mantener su propia lógica manual de detección y de variants alternativas para ese caso. Para cualquiera que esté evaluando cuánta lógica propia necesita mantener alrededor de accesibilidad de movimiento en un proyecto nuevo, es necesario confirmar primero qué resuelve la versión de la librería que se está usando antes de reconstruir esa lógica manualmente.


Entiscore está disponible en entiscore.vercel.app. Construido con Next.js, TypeScript, Supabase y Claude API para el hackathon Kiro powered by AWS de Código Facilito.