wandres.dev
WAAPI III · Composición y efectos

El problema: dos animaciones sobre la misma propiedad

Por qué una animación de flotación y una de hover no pueden convivir sobre transform, los apaños clásicos y por qué ninguno escala.

⏱ 17 min

Tienes una tarjeta que flota en bucle y quieres que además se levante al pasar el ratón. Las dos cosas son movimiento vertical, las dos se escriben con transform, y en cuanto las pones juntas descubres que solo funciona una: la segunda pisa a la primera y la flotación se detiene en seco. El problema no es de CSS ni de WAAPI, es del modelo: por defecto una animación reemplaza el valor de las que están por debajo. La plataforma tiene una respuesta específica para esto, y antes de verla conviene entender bien por qué los apaños habituales no llegan.

🎯 Al terminar esta lección sabrás
  • Reproducir el conflicto entre dos animaciones sobre la misma propiedad.
  • Explicar qué es el valor subyacente y en qué orden se apilan las animaciones.
  • Evaluar los tres apaños clásicos y sus límites.
  • Reconocer cuándo las propiedades individuales de transform bastan y cuándo no.

El conflicto, en concreto

const tarjeta = document.querySelector('.tarjeta');

// Flotacion permanente
tarjeta.animate(
  { transform: ['translateY(0)', 'translateY(-8px)', 'translateY(0)'] },
  { duration: 3000, iterations: Infinity, easing: 'ease-in-out' }
);

// Reaccion al puntero
tarjeta.addEventListener('pointerenter', () => {
  tarjeta.animate(
    { transform: ['translateY(0)', 'translateY(-20px)'] },
    { duration: 240, fill: 'forwards' }
  );
});

En cuanto el puntero entra, la flotación desaparece. No se pausa: sigue corriendo, avanzando y produciendo valores que nadie ve. La segunda animación se creó después, va después en el orden de composición, y su modo por defecto es reemplazar.

flowchart TB
B[valor base del elemento] --> A1[animacion de flotacion]
A1 --> A2[animacion de hover]
A2 --> R[transform final]
N1[con replace la de hover ignora lo que llega de abajo] --> A2
style B fill:#94e2d5,color:#11111b
style A1 fill:#89b4fa,color:#11111b
style A2 fill:#f38ba8,color:#11111b
style R fill:#f9e2af,color:#11111b
style N1 fill:#f9e2af,color:#11111b

El concepto que hay que fijar es el valor subyacente. Cuando el motor calcula el transform de este elemento, empieza por el valor de la cascada y va aplicando las animaciones en orden. Cada una recibe como entrada lo que produjeron las anteriores, y decide qué hacer con ello. Con replace, que es el modo por defecto, la decisión es “descártalo y usa el mío”.

El orden es fijo y está definido: primero las transiciones CSS, luego las animaciones CSS por orden de animation-name, y por último las animaciones de JavaScript por orden de creación. La de arriba en la pila manda. Con dos animaciones de JavaScript, la más nueva gana; y como fill: forwards la mantiene viva para siempre, gana también cuando el puntero se va.

Los tres apaños y por qué se quedan cortos

Elementos anidados. El más antiguo: un envoltorio que flota y un hijo que reacciona al puntero. Cada transform vive en su propio elemento y no compiten.

<div class="flota"><div class="tarjeta">...</div></div>

Funciona y sigue siendo razonable para dos capas. Deja de serlo cuando son tres o cuatro efectos independientes sobre la misma cosa, porque cada uno exige un nivel más de marcado que no significa nada semánticamente, y cada nivel es un elemento más que el motor tiene que disponer y componer. Además rompe en cuanto los efectos necesitan orígenes de transformación distintos o alguno depende del tamaño del contenido.

Propiedades individuales. El CSS moderno separa translate, rotate y scale como propiedades propias, animables por separado. Es una mejora real y resuelve una parte del problema:

tarjeta.animate({ translate: ['0 0', '0 -8px', '0 0'] }, { duration: 3000, iterations: Infinity });
tarjeta.animate({ scale: [1, 1.04] }, { duration: 240, fill: 'forwards' });

Estas dos conviven porque tocan propiedades distintas. Pero solo desplazan el problema: dos efectos que necesiten desplazar el elemento siguen chocando, porque los dos escriben translate. Y hay un detalle de orden que sorprende: las propiedades individuales se aplican siempre antes que transform, en el orden translate, rotate, scale y después transform, así que no puedes intercalar libremente.

Componer a mano con variables. Cada efecto escribe su parte en una custom property registrada y un transform estático las combina:

@property --flotar { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --alzar  { syntax: "<length>"; inherits: false; initial-value: 0px; }

.tarjeta { transform: translateY(calc(var(--flotar) + var(--alzar))); }

Es la solución más flexible y la que más se ve en bases de código grandes. Su coste ya lo conocemos: animar variables registradas obliga a recalcular el estilo en cada frame y renuncia por completo a la aceleración en el compositor. Con una tarjeta no importa; con una rejilla de sesenta, la diferencia entre esto y un transform compuesto en el compositor es la diferencia entre sesenta frames por segundo y veinticinco.

Lo que hace falta de verdad

Los tres apaños comparten un diagnóstico: intentan evitar que dos animaciones se encuentren sobre la misma propiedad, en vez de definir qué debe pasar cuando se encuentran. Lo que hace falta no es separación, es una regla de combinación.

Esa regla existe y se llama composite. Tiene tres valores, está disponible en WAAPI por efecto y por keyframe, y en CSS como animation-composition. Con ella la animación de hover puede declarar que su contribución se suma al valor subyacente en vez de sustituirlo, y el conflicto desaparece sin marcado extra, sin variables y sin renunciar al compositor:

tarjeta.addEventListener('pointerenter', () => {
  tarjeta.animate(
    { transform: ['translateY(0)', 'translateY(-20px)'] },
    { duration: 240, fill: 'forwards', composite: 'add' }
  );
});

Una palabra. La tarjeta sigue flotando y además se levanta veinte píxeles, y el desplazamiento total en cada frame es la suma de los dos. Es la respuesta directa al problema, y lleva años en la plataforma sin que casi nadie la use.

Nivel dios

La razón por la que composite es tan desconocido no es que sea nuevo: es que el modelo por defecto oculta el problema hasta que ya has elegido una arquitectura. Mientras cada efecto de tu interfaz sea único por elemento, replace funciona perfectamente y nadie se plantea que exista una alternativa. El conflicto aparece tarde, cuando alguien añade el quinto efecto a un componente que ya tiene cuatro, y para entonces la respuesta que da el equipo casi siempre es “envuélvelo en otro div”, porque es la que funciona sin aprender nada. Así se llega a componentes con cinco niveles de envoltorio cuyo único propósito es no compartir transform. Merece la pena reconocer el síntoma pronto: si estás añadiendo un elemento contenedor cuyo único contenido es otro elemento y cuya única declaración es una animación, lo que necesitas es composite: 'add'. Ese envoltorio no aporta estructura, aporta un espacio de nombres para una propiedad, y la plataforma ya tiene una forma mejor de dártelo.

Antes de seguir

Dos avisos para no llevarse una decepción. El primero: composite no arregla propiedades cuya combinación no esté definida. Sumar dos color o dos display no significa nada, y en esos casos el motor cae a replace sin decir nada. La composición tiene sentido para números, longitudes, listas de transformación, listas de sombras y filtros; para lo demás no.

El segundo: sumar no siempre es lo que quieres. Dos animaciones de escala sumadas producen un resultado que crece más rápido de lo que esperas, y ahí entra la diferencia entre add y accumulate, que es exactamente la distinción que la especificación introduce para resolver ese caso.

⚔️ Reto práctico

Monta el conflicto de la flotación y el hover tal cual está al principio de la lección y compruébalo. Después arréglalo de las tres formas clásicas —envoltorio, propiedades individuales, variables registradas— y anota cuántas líneas y cuántos elementos cuesta cada una. Por último añade composite: 'add' y compara. Es la comparación que justifica el resto del nivel.