Motion sin framework: la API híbrida y el peso real
La función animate de Motion en vanilla, qué significa que sea híbrida sobre WAAPI, las entradas parciales y sus tamaños, y cómo medir lo que de verdad envías.
Motion tiene una segunda cara que casi nadie usa y que resuelve un caso muy concreto: animar bien sin cargar un framework de animación. La función animate de la entrada vanilla se apoya en element.animate() del navegador siempre que puede, y solo baja a su propio bucle cuando la propiedad no es animable de forma nativa. Eso produce una librería con dos perfiles de peso muy distintos según lo que uses, y entender la frontera es la diferencia entre enviar dos kilobytes o veinte.
- Escribir animaciones con la API imperativa de Motion sin ningún framework.
- Explicar qué significa que una librería sea híbrida sobre WAAPI y qué cae de cada lado.
- Elegir entre
motion/miniymotioncon un criterio que no sea el peso a ciegas. - Medir el coste real de una librería de animación en tu bundle en lugar de citar cifras de un blog.
La API imperativa
El punto de entrada es el mismo paquete, sin el sufijo de framework. Las duraciones van en segundos, como en GSAP, no en milisegundos.
import { animate, stagger, inView, scroll, hover, press } from "motion";
animate("#caja", { opacity: 1, y: 0 }, { duration: 0.4, ease: [0.2, 0, 0, 1] });
Los valores pueden ser un destino o una secuencia. Una secuencia de tres valores reparte el tiempo entre ellos, y offset controla el reparto exacto, igual que un @keyframes:
animate(
"#pastilla",
{ scale: [1, 1.12, 1], backgroundColor: ["#89b4fa", "#a6e3a1", "#89b4fa"] },
{ duration: 0.5, offset: [0, 0.35, 1], ease: "easeInOut" }
);
stagger devuelve una función de retardo que Motion evalúa por elemento, con la misma idea que en GSAP:
animate(
".tarjeta",
{ opacity: [0, 1], y: [16, 0] },
{ duration: 0.32, delay: stagger(0.06, { startDelay: 0.1 }) }
);
Los muelles son un tipo de transición, no una curva. La parametrización por rigidez y amortiguación es la física directa; la parametrización por bounce y duration es la de diseño, y es la que deberías usar salvo que sepas exactamente qué significan las otras dos:
animate("#panel", { x: 0 }, { type: "spring", bounce: 0.25, duration: 0.6 });
animate("#panel", { x: 0 }, { type: "spring", stiffness: 300, damping: 28, mass: 1 });
Y hay tres primitivas de entrada que ahorran bastante código repetido. inView observa la entrada en el viewport con IntersectionObserver y admite una función de limpieza para el momento de salida. scroll conecta una animación a una timeline de scroll, usando la nativa cuando el navegador la tiene. hover y press normalizan puntero, táctil y teclado, incluyendo el caso de que el puntero salga del elemento con el botón pulsado.
inView(".seccion", (elemento) => {
animate(elemento, { opacity: [0, 1], y: [24, 0] }, { duration: 0.5 });
return () => animate(elemento, { opacity: 0 }, { duration: 0.2 });
});
scroll(animate("#barra", { scaleX: [0, 1] }, { ease: "linear" }));
animate devuelve un objeto de animación con play, pause, cancel, complete, time, speed y una promesa en finished. Es la misma forma que el objeto Animation de WAAPI, deliberadamente.
Qué significa híbrida
Cuando animas una propiedad que el navegador sabe interpolar y que no requiere que Motion lea valores intermedios, la librería construye un KeyframeEffect y llama a element.animate(). La consecuencia práctica es enorme: la animación de transform u opacity pasa al hilo del compositor, sobrevive a un hilo principal bloqueado y aparece en el panel de animaciones de las DevTools como cualquier animación nativa. Motion no está en el bucle; solo la creó.
Cuando la propiedad no se puede delegar, Motion cae a su propio bucle. Los casos que caen de este lado son concretos:
- Muelles. No hay muelles en WAAPI. Motion los ejecuta en su bucle, o —y esto es lo interesante— los pre-samplea a una curva
linear()con decenas de puntos y se la pasa a WAAPI, con lo que un muelle sin interrupciones sí llega al compositor. Un muelle que puede ser interrumpido a mitad, no, porque hay que conocer la velocidad instantánea. - Propiedades independientes en navegadores que no las tienen. Motion escribe
x,y,scaleyrotatecomo partes de untransformcompuesto y ordenado, que es más predecible que confiar en el orden de las propiedades individuales. - Valores que no son CSS. Animar un objeto JavaScript, un atributo de SVG o un
MotionValueque alimenta varias propiedades a la vez. - Interrupciones con velocidad. Cambiar de destino a mitad de camino conservando el momento.
La regla que se deriva de esto es simple y ahorra sorpresas: si quieres que tu animación corra en el compositor, animas transform y opacity con una curva de Bézier y sin interrupciones. En cuanto pides un muelle interrumpible, has vuelto al hilo principal. No es un defecto de Motion; es que la plataforma no ofrece otra cosa.
Abre el panel de animaciones de las DevTools y dispara la animación. Si aparece como un grupo con su barra de tiempo, la creó element.animate() y el navegador la gestiona. Si no aparece nada pero el elemento se mueve, es el bucle de la librería escribiendo estilo en línea.
Las entradas parciales
Motion se distribuye con varios puntos de entrada, y la diferencia de peso entre ellos es de un orden de magnitud.
| Entrada | Qué trae | Orden de magnitud comprimido |
|---|---|---|
motion/mini |
animate solo sobre WAAPI, sin muelles ni bucle propio |
unidades de kB |
motion |
animate híbrida, muelles, stagger, scroll, gestos |
del orden de 18 kB |
motion/react con motion.div |
Todo lo anterior más componentes, layout, gestos, presencia | del orden de 34 kB |
motion/react con m y LazyMotion |
Núcleo mínimo, resto cargado bajo demanda | del orden de 5 kB iniciales |
Las cifras están redondeadas a propósito y envejecerán; el proyecto trabaja activamente en reducirlas. Lo que no envejece es la forma de la curva: el salto grande está entre lo que puede delegar en WAAPI y lo que necesita bucle propio, y el segundo salto está entre la API imperativa y el sistema de componentes.
motion/mini merece una mirada aparte porque es la opción que casi nadie considera y es la correcta más veces de lo que parece. Es una envoltura fina sobre element.animate() con la ergonomía de Motion: valores como arrays, stagger, la misma firma. No trae muelles, no interrumpe con velocidad, no anima nada que WAAPI no anime. A cambio pesa lo que pesa una función de utilidad. Para una landing donde lo único que haces es aparecer secciones al hacer scroll, no necesitas nada más.
import { animate } from "motion/mini";
import { stagger } from "motion";
animate(".item", { opacity: [0, 1], transform: ["translateY(12px)", "none"] }, {
duration: 0.3,
delay: stagger(0.05),
});
En React, el patrón equivalente para no arrastrar el componente completo es LazyMotion con el componente m: m.div tiene la misma API que motion.div pero no incluye las funcionalidades, que se cargan como un módulo aparte. Solo tiene sentido si de verdad has medido que los treinta y cuatro kilobytes te importan; si no, es complejidad a cambio de nada.
Medir en lugar de citar
Ninguna cifra de un blog describe tu bundle. Tres formas de saber la tuya, en orden de fiabilidad creciente.
El analizador del bundler. Con Vite o Rollup, rollup-plugin-visualizer genera un treemap del build de producción donde ves el tamaño real de cada módulo después de tree-shaking. Es lo primero que hay que mirar, y suele desmentir la cifra que citaba el blog en las dos direcciones.
npm i -D rollup-plugin-visualizer
// vite.config.js
import { defineConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [visualizer({ gzipSize: true, brotliSize: true, open: true })],
});
El diferencial. La única medida que responde a la pregunta que de verdad tienes. Construye con la librería, anota el total comprimido, quítala, sustituye por la alternativa, construye otra vez. La diferencia entre los dos totales es lo que cuesta la decisión. Es distinto del tamaño del paquete porque el tree-shaking depende de qué importas y de qué más hay en el grafo.
npm run build && du -sh dist/assets/*.js | sort -h | tail -5
El panel de red con throttling. El bundle comprimido es un número; el tiempo hasta que la animación puede ejecutarse es otro. Un módulo de veinte kilobytes que se descarga en paralelo con el resto y se parsea en cinco milisegundos no retrasa nada. El mismo módulo en la cadena crítica de un dispositivo lento sí. Mira la cascada, no la báscula.
Y una consideración que pesa más que todas las anteriores y no aparece en ninguna comparativa: si la animación no es visible en el primer pintado, su librería no debería estar en el bundle inicial. Un import() dinámico disparado por IntersectionObserver o por la primera interacción convierte cualquier discusión de kilobytes en irrelevante, porque el coste deja de estar en la ruta crítica.
const observador = new IntersectionObserver(async ([entrada], obs) => {
if (!entrada.isIntersecting) return;
obs.disconnect();
const { animate } = await import("motion");
animate(entrada.target.querySelectorAll(".item"), { opacity: [0, 1] }, { duration: 0.4 });
}, { rootMargin: "200px" });
observador.observe(document.querySelector("#galeria"));
Durante años el debate fue binario: o usabas WAAPI y te comías sus carencias, o usabas una librería con bucle propio y renunciabas al compositor. Las dos posturas eran defendibles y las dos eran malas, porque el reparto correcto no es por librería sino por animación. La observación que cambia el diseño es que la mayoría de las animaciones de una interfaz real son triviales para WAAPI —una opacidad, un desplazamiento, una escala, con una curva fija y sin interrupción— y solo una minoría necesita lo que WAAPI no da. Una arquitectura híbrida clasifica cada animación en el momento de crearla y manda cada una por el camino que le corresponde, de modo que el noventa por ciento del movimiento de tu producto acaba en el compositor sin que tú hagas nada y el diez por ciento restante paga el hilo principal porque no hay alternativa. El detalle técnico más bonito de esta arquitectura es el pre-sampleado de muelles: un muelle es una solución analítica de una ecuación diferencial, y si sabes que no va a ser interrumpido puedes evaluarla de antemano en cincuenta puntos, generar una función linear() con esos puntos y entregársela a WAAPI, que la ejecutará en el compositor como cualquier otra curva. El muelle deja de ser un bucle y pasa a ser una tabla. Solo pierdes esa ventaja cuando el muelle tiene que responder a una interrupción, porque entonces necesitas la velocidad instantánea en un instante arbitrario y una tabla no te la da. Entender esa frontera —delegable mientras nadie pueda cambiarle el destino a mitad— es lo que te permite predecir, antes de escribir nada, si una animación tuya va a sobrevivir a un hilo principal ocupado. Y es la misma frontera que explica por qué motion/mini puede pesar lo que pesa: no es que sea una versión recortada, es que solo cubre el lado delegable, y ese lado no necesita casi código.