wandres.dev
MOTION Y LAS ALTERNATIVAS · El resto del ecosistema

Motion en React: el modelo declarativo a fondo

Cómo Motion convierte el estado en movimiento, por qué AnimatePresence existe, qué hace realmente la prop layout, y en qué se equivoca quien viene de una API imperativa.

⏱ 22 min

Motion —el proyecto que hasta 2024 se llamaba Framer Motion— no es GSAP con sintaxis de React. Es un modelo distinto: en lugar de decirle a la librería qué hacer, le describes cómo se ve cada estado y ella deduce el movimiento. Ese cambio de responsabilidad resuelve de golpe los dos problemas que más código imperativo generan en una aplicación con estado: las animaciones de salida y las transiciones de layout.

🎯 Al terminar esta lección sabrás
  • Escribir animaciones dirigidas por estado con initial, animate y variants.
  • Explicar por qué un elemento desmontado no se puede animar y qué hace AnimatePresence al respecto.
  • Usar layout y layoutId y saber qué propiedades toca realmente y cuáles corrompe.
  • Identificar los tres errores típicos de quien llega desde una API imperativa.

El estado es la fuente del movimiento

La unidad básica es un componente motion que envuelve un elemento del DOM. initial describe el estado del que parte, animate el estado hacia el que va, y transition cómo llegar.

import { motion } from "motion/react";

export function Aviso({ visible }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: -8 }}
      animate={{ opacity: visible ? 1 : 0, y: visible ? 0 : -8 }}
      transition={{ duration: 0.24, ease: [0.2, 0, 0, 1] }}
      className="aviso"
    >
      Guardado
    </motion.div>
  );
}

Lo importante no es la sintaxis, es lo que no hay: no hay un useEffect que dispare la animación, no hay una referencia al nodo, y no hay que acordarse de cancelar nada cuando visible cambia dos veces en cien milisegundos. Motion compara el objeto animate con el anterior en cada render y arranca solo las propiedades que cambiaron, desde el valor actual, esté a medio camino o no.

Cuando el número de estados crece, escribir ternarios dentro del objeto se hace ilegible. Para eso están las variantes: un diccionario de estados con nombre.

const panel = {
  cerrado: { opacity: 0, x: "-100%" },
  abierto: { opacity: 1, x: "0%" },
};

<motion.aside variants={panel} initial="cerrado" animate={abierto ? "abierto" : "cerrado"} />

Las variantes tienen una propiedad que las hace algo más que azúcar sintáctico: se propagan por el árbol. Si un hijo motion declara variantes con los mismos nombres y no recibe un animate propio, hereda el nombre del estado del padre. Eso permite orquestar un contenedor y sus hijos desde un único cambio de estado, con staggerChildren y delayChildren en la transición del padre.

const lista = {
  oculta: {},
  visible: { transition: { staggerChildren: 0.05, delayChildren: 0.1 } },
};
const item = {
  oculta: { opacity: 0, y: 12 },
  visible: { opacity: 1, y: 0 },
};

<motion.ul variants={lista} initial="oculta" animate="visible">
  {datos.map((d) => (
    <motion.li key={d.id} variants={item}>{d.texto}</motion.li>
  ))}
</motion.ul>

Esto es lo más cerca que llega el modelo declarativo a una timeline, y es honesto reconocer que llega hasta aquí y no más lejos. Puedes escalonar hijos y encadenar niveles de anidamiento, pero no puedes decir “el tercer elemento arranca doscientos milisegundos antes de que el segundo termine su rebote”. Si necesitas eso, no necesitas variantes: necesitas una timeline.

Por qué existe AnimatePresence

Este es el problema que justifica la librería entera. Cuando visible pasa a falso y el JSX deja de devolver el nodo, React lo desmonta. Un nodo desmontado no está en el DOM. No hay nada que animar. La animación de salida es imposible por construcción.

AnimatePresence resuelve esto invirtiendo el control del desmontaje: mantiene una copia de los hijos que han desaparecido del árbol, los deja en el DOM, ejecuta la variante exit, y solo entonces los quita de verdad.

import { motion, AnimatePresence } from "motion/react";

export function Modal({ abierto, onCerrar, children }) {
  return (
    <AnimatePresence>
      {abierto && (
        <motion.div
          key="fondo"
          className="fondo"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.18 }}
          onClick={onCerrar}
        >
          <motion.div
            className="dialogo"
            initial={{ opacity: 0, scale: 0.96, y: 8 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.98, y: 4 }}
            transition={{ duration: 0.22, ease: [0.2, 0, 0, 1] }}
            onClick={(e) => e.stopPropagation()}
          >
            {children}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

Tres reglas que se incumplen constantemente y producen bugs difíciles de leer.

La key es obligatoria y tiene que ser estable. AnimatePresence identifica a sus hijos por key para saber cuál se fue. Si la key cambia en cada render —porque la generas con un índice o con Math.random()— la librería cree que el elemento anterior salió y uno nuevo entró, y verás salidas y entradas fantasma.

El condicional va dentro, no fuera. AnimatePresence tiene que estar montado permanentemente para poder observar la desaparición. Si envuelves el AnimatePresence en el condicional, desaparece a la vez que su hijo y no queda nadie para retrasarlo.

El nodo que sale sigue ocupando espacio hasta que termina. Durante la salida, el elemento está en el DOM y en el flujo. En una lista, eso significa que el hueco no se cierra hasta el final. El modo popLayout lo saca del flujo con position: absolute para que los hermanos se recoloquen desde el primer fotograma, y el modo wait espera a que el saliente termine antes de montar el entrante, que es lo que quieres en una transición entre pantallas.

⚠️
popLayout necesita un contenedor posicionado

mode="popLayout" posiciona el saliente de forma absoluta respecto a su contenedor. Si ese contenedor no tiene position: relative, el elemento saldrá volando hasta el bloque contenedor más cercano, normalmente el body. Es el fallo más común con este modo y no da ningún error en consola.

Lo que hace layout y lo que no

La prop layout es la implementación de FLIP dentro del modelo declarativo. Marcas un componente con layout y, cuando su posición o su tamaño cambian por cualquier motivo —un hermano que entra, un cambio de dirección del contenedor, un grid que se reordena—, Motion mide antes y después y anima la diferencia con transform.

<motion.div layout transition={{ duration: 0.3, ease: [0.2, 0, 0, 1] }} />

layoutId extiende la idea a dos componentes distintos: si un elemento con layoutId="tarjeta-7" desaparece y otro con el mismo layoutId aparece en el mismo commit, Motion los trata como el mismo objeto y anima de la caja del primero a la del segundo. Es el efecto de tarjeta que se expande a pantalla completa, sin escribir una sola medida.

Ahora la parte que la documentación menciona de pasada y que te va a morder. Motion anima el cambio de layout con transform, y una escala aplicada a un contenedor deforma todo lo que hay dentro. Un texto dentro de una caja que pasa de cien a trescientos píxeles de ancho se estira horizontalmente durante la transición. Un border-radius uniforme se convierte en una elipse. Motion corrige ambas cosas, pero la corrección tiene condiciones:

  • Los hijos que también deban contrarrestar la escala tienen que llevar layout ellos mismos. Un hijo sin layout se deforma.
  • El border-radius se corrige si está declarado en el estilo en línea o si Motion lo puede leer; con radios en porcentaje el resultado es peor que con radios en píxeles.
  • box-shadow sufre el mismo estiramiento y no se corrige. Si el elemento tiene sombra y cambia mucho de tamaño, se nota.

Y una regla de rendimiento que se olvida: cada componente con layout participa en una medición en cada render que cambie el layout. Marcar cien elementos de una lista con layout significa cien lecturas de geometría antes de pintar. En una lista larga, eso es exactamente el trabajo que estabas intentando evitar. Marca el contenedor y los pocos elementos cuyo movimiento se ve, no todo.

Los tres errores de quien viene de lo imperativo

Buscar el equivalente de la timeline. No lo hay, y buscarlo lleva a encadenar onAnimationComplete con estados intermedios, que es la peor versión de las dos cosas. Si el movimiento es una secuencia coreografiada con solapes calculados, usa una API imperativa —la animate de Motion, o GSAP— y deja el modelo declarativo para lo que sí es: estados de interfaz.

Animar propiedades que no están en el estado. Motion interpola entre lo que estaba y lo que hay. Si escribes valores directamente en el style del nodo desde otro sitio, o si una clase CSS cambia la misma propiedad, tienes dos escritores sobre el mismo valor y el resultado depende del orden de ejecución. Elige uno.

Ignorar useMotionValue y useTransform para lo continuo. El ciclo de render de React no es un buen sitio para valores que cambian sesenta veces por segundo: cada actualización de estado es un render del árbol. Un MotionValue es un valor reactivo que vive fuera de React y escribe directo en el estilo del nodo, sin provocar renders. Es lo que tienes que usar para todo lo que siga a un puntero o a un scroll.

import { motion, useMotionValue, useTransform, useSpring } from "motion/react";

export function TarjetaInclinada({ children }) {
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const rotarX = useSpring(useTransform(y, [-120, 120], [8, -8]), { stiffness: 260, damping: 24 });
  const rotarY = useSpring(useTransform(x, [-120, 120], [-8, 8]), { stiffness: 260, damping: 24 });

  function mover(evento) {
    const caja = evento.currentTarget.getBoundingClientRect();
    x.set(evento.clientX - caja.left - caja.width / 2);
    y.set(evento.clientY - caja.top - caja.height / 2);
  }

  return (
    <motion.div
      style={{ rotateX: rotarX, rotateY: rotarY, transformPerspective: 600 }}
      onPointerMove={mover}
      onPointerLeave={() => { x.set(0); y.set(0); }}
    >
      {children}
    </motion.div>
  );
}

Ese componente no provoca ni un solo render de React mientras el puntero se mueve. Escribir lo mismo con useState provocaría uno por evento de puntero, y en un árbol grande eso es un long task en cada movimiento del ratón.

Motion no gana por sintaxis: gana porque conoce el ciclo de vida del componente

La ventaja estructural de una librería acoplada al framework es que puede ver cosas que una librería agnóstica no puede ver, y las dos que ve son justo las dos que más código imperativo generan. La primera es el desmontaje: React sabe con exactitud en qué commit un nodo deja de existir, y AnimatePresence se engancha ahí para retrasarlo. Una librería agnóstica no tiene forma de enterarse, así que la responsabilidad recae en ti: en cada sitio donde quites un nodo tienes que acordarte de animar primero y borrar después, y la corrección de tu aplicación depende de que ni tú ni nadie olvide hacerlo en ninguno de los cuarenta sitios. La segunda es el cambio de layout: React sabe cuándo va a mutar el árbol, y Motion puede medir en el momento exacto entre la mutación y el pintado, que es la única ventana en la que FLIP es correcto y barata. Fuera del framework, esa ventana la tienes que construir tú con un MutationObserver o llamando a mano antes y después de cada cambio. Nada de esto significa que el modelo declarativo sea superior en abstracto; significa que hay una clase de errores que en el modelo declarativo es imposible cometer, y que esa clase es precisamente la que más aparece en revisiones de código de aplicaciones con estado. El contrapunto, igual de real, es que el modelo tiene un techo: en cuanto necesitas una relación temporal que no sea “esto entra, esto sale, esto se escalona”, te encuentras encadenando callbacks y estados intermedios para simular una timeline, y el resultado es más frágil que la timeline que estabas evitando. La regla práctica que sale de aquí es de reparto, no de bando: lo que es función del estado, declarativo; lo que es una secuencia con su propio tiempo, imperativo. Y las dos cosas pueden convivir en el mismo proyecto sin que pase nada, porque no compiten por el mismo trabajo.

⚔️ Rompe y arregla AnimatePresence
  1. Monta una lista con AnimatePresence y key basada en el índice del array. Borra un elemento del medio y observa la salida fantasma.
  2. Cambia la key al identificador estable y comprueba que ahora sale el correcto.
  3. Añade mode="popLayout" sin position: relative en el contenedor y localiza dónde acaba el elemento.
  4. Marca los hijos con layout y comprueba el hueco cerrándose desde el primer fotograma.
  5. Sustituye un seguimiento de puntero hecho con useState por useMotionValue y compara el número de renders en el profiler de React.