wandres.dev
WAAPI I · El método animate

Lo que se puede expresar aquí y no en CSS

Keyframes calculados en tiempo de ejecución, animaciones que dependen de medidas del DOM, y el patrón FLIP escrito con la API nativa.

⏱ 20 min

Si WAAPI solo sirviera para escribir en JavaScript lo mismo que se escribe en CSS, no valdría la pena: el CSS es más declarativo, más barato de mantener y llega antes al compositor. Lo que justifica la API es la clase de animaciones que no se pueden escribir de antemano porque sus valores dependen de algo que solo se conoce en tiempo de ejecución: una medida del DOM, un dato del servidor, la posición del puntero, el número de elementos de una lista.

🎯 Al terminar esta lección sabrás
  • Construir keyframes a partir de medidas tomadas del DOM.
  • Generar un número variable de paradas desde un array de datos.
  • Implementar el patrón FLIP con animate() sin librerías.
  • Reconocer cuándo el CSS sigue siendo la respuesta correcta.

Valores que solo existen en tiempo de ejecución

El caso más simple: quieres que un panel se despliegue hasta su altura natural. En CSS no puedes escribirlo porque no sabes cuánto mide, y height: auto no interpola sin interpolate-size. Con WAAPI mides y animas:

function desplegar(panel) {
  const destino = panel.scrollHeight;
  return panel.animate(
    { height: ['0px', `${destino}px`], opacity: [0, 1] },
    { duration: 320, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
  ).finished;
}

La medida se toma una vez, en el instante de crear la animación, y se convierte en una constante dentro de los keyframes. No hay lectura de layout por frame, no hay reflujo forzado en el bucle: solo uno al principio, que es inevitable porque la medida hace falta.

El segundo caso, más interesante, es cuando el número de keyframes depende de los datos. Una animación de un gráfico que recorre una serie de valores no tiene un número fijo de paradas:

function reproducirSerie(barra, serie) {
  const maximo = Math.max(...serie);
  const alturas = serie.map((v) => `scaleY(${(v / maximo).toFixed(4)})`);
  return barra.animate(
    { transform: alturas },
    { duration: serie.length * 90, easing: 'linear', fill: 'forwards' }
  );
}

Con doce puntos genera doce keyframes, con doscientos genera doscientos. En CSS habría que emitir una regla @keyframes por serie e insertarla en una hoja de estilo, lo cual invalida los selectores del documento entero cada vez y deja basura acumulándose en el CSSOM.

El tercero es la interpolación con una curva que el CSS no sabe expresar. Puedes muestrear cualquier función —un muelle amortiguado, una trayectoria de Bézier, una lectura de un sensor— y volcarla en paradas:

function muelle(desde, hasta, pasos = 60, rigidez = 8, amortiguacion = 0.4) {
  const salida = [];
  for (let i = 0; i < pasos; i++) {
    const t = i / (pasos - 1);
    const decaimiento = Math.exp(-amortiguacion * rigidez * t);
    const oscilacion = Math.cos(rigidez * Math.sqrt(1 - amortiguacion ** 2) * t);
    const p = 1 - decaimiento * oscilacion;
    salida.push(desde + (hasta - desde) * p);
  }
  return salida;
}

el.animate(
  { transform: muelle(0, 240).map((x) => `translateX(${x.toFixed(2)}px)`) },
  { duration: 900, easing: 'linear' }
);

El easing: 'linear' es imprescindible: la curva ya está en los valores, y aplicar además una curva de temporización la deformaría. Es el mismo principio que rige linear() en CSS, con la diferencia de que aquí generas las paradas con la función real en vez de con una aproximación escrita a mano.

FLIP con la API nativa

El patrón FLIP —First, Last, Invert, Play— es el caso donde WAAPI deja de ser una comodidad y pasa a ser la herramienta. La idea es animar un cambio de layout que el navegador ya ha hecho: mides dónde estaba el elemento, dejas que el layout cambie, mides dónde está, y animas desde la diferencia hasta cero.

function flip(elemento, mutar) {
  // First: donde esta ahora
  const primero = elemento.getBoundingClientRect();

  // El cambio de layout: reordenar, cambiar de contenedor, quitar una clase
  mutar();

  // Last: donde ha quedado
  const ultimo = elemento.getBoundingClientRect();

  const dx = primero.left - ultimo.left;
  const dy = primero.top - ultimo.top;
  const sx = primero.width / ultimo.width;
  const sy = primero.height / ultimo.height;

  if (dx === 0 && dy === 0 && sx === 1 && sy === 1) return Promise.resolve();

  // Invert y Play: parte del delta y vuelve a la identidad
  return elemento.animate(
    {
      transformOrigin: ['top left', 'top left'],
      transform: [`translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, 'none'],
    },
    { duration: 400, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
  ).finished;
}

Uso real, reordenando una lista:

const lista = document.querySelector('ul');
const items = [...lista.children];

document.querySelector('#ordenar').addEventListener('click', () => {
  const rects = new Map(items.map((el) => [el, el.getBoundingClientRect()]));

  items
    .sort((a, b) => a.textContent.localeCompare(b.textContent))
    .forEach((el) => lista.appendChild(el));

  for (const el of items) {
    const antes = rects.get(el);
    const ahora = el.getBoundingClientRect();
    const dx = antes.left - ahora.left;
    const dy = antes.top - ahora.top;
    if (dx || dy) {
      el.animate(
        { transform: [`translate(${dx}px, ${dy}px)`, 'none'] },
        { duration: 380, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
      );
    }
  }
});

Nada de esto se puede escribir en CSS, porque los valores dx y dy no existen hasta que el layout ha ocurrido. Y fíjate en el rendimiento: aunque el layout cambia de verdad, lo que se anima es transform, así que cada elemento se mueve en el compositor. Es la razón por la que FLIP se ve fluido con cien elementos mientras que animar top con cien elementos no.

Nivel dios

La versión de arriba tiene un fallo de precisión que se ve en cuanto la ejecutas dos veces seguidas rápido: si un elemento ya está animándose, su getBoundingClientRect() devuelve la posición transformada actual, no la posición de layout. Al medir “First” con una animación en curso, el delta que calculas parte de donde el elemento se ve, no de donde el layout lo dejó, y el segundo FLIP arranca desde un sitio equivocado. Curiosamente, eso es exactamente lo que quieres, no un bug: el elemento continúa desde donde estaba en vez de saltar. Lo que sí es un bug de verdad es no cancelar la animación anterior antes de crear la nueva, porque las dos siguen vivas y la más nueva gana solo mientras dure. En cuanto termina, la vieja —que aún no ha acabado— vuelve a mandar y el elemento salta hacia atrás. La línea que falta es el.getAnimations().forEach(a => a.cancel()) justo después de medir “Last” y antes de crear la animación. Medir primero, cancelar después: si cancelas antes de medir, el elemento salta a su posición de layout y pierdes la continuidad que hacía bonito el efecto.

Cuándo el CSS sigue ganando

WAAPI no sustituye al CSS y la tentación de moverlo todo a JavaScript es un error caro. Tres razones concretas:

La primera es el momento de arranque. Una animación CSS se aplica cuando el motor calcula el estilo del elemento, sin esperar a que se ejecute ningún script. Una animación WAAPI necesita que el bundle haya cargado, se haya parseado y se haya ejecutado. Para animaciones de entrada por encima del pliegue, la diferencia es visible.

La segunda es el estado. Las animaciones CSS son función del estado del DOM: una clase, un atributo, una consulta de medios. Ese acoplamiento es una ventaja, porque el estado ya está representado en algún sitio y no hay que sincronizarlo. Con WAAPI el estado de las animaciones vive aparte y tienes que mantenerlo tú.

La tercera es la cantidad. Cien elementos con la misma animación CSS son una regla @keyframes y un selector; cien elementos con animate() son cien objetos Animation en memoria. Cuando la animación es idéntica para todos, el CSS es más barato en creación y en gestión.

La regla que funciona: CSS para todo lo que se pueda escribir de antemano, WAAPI para lo que dependa de valores calculados o necesite control de reproducción. Y cuando dudes, empieza en CSS y muévelo cuando te haga falta el objeto Animation.

⚔️ Reto práctico

Implementa el FLIP de la lista de arriba y comprueba que funciona. Después haz clic dos veces seguidas antes de que termine la primera animación y observa el salto hacia atrás. Añade el cancel() en el punto que indica la nota y verifica que el salto desaparece. Prueba también a ponerlo antes de medir “Last” para ver el otro fallo.