KeyframeEffect: el efecto como objeto independiente
Separar la descripción del movimiento del elemento que lo sufre, clonarla, reasignarla en marcha y cambiar sus keyframes o su temporización sin reiniciar nada.
Element.animate() fabrica tres objetos y te devuelve uno. El del medio, el KeyframeEffect, es el que describe qué movimiento es con independencia de quién lo hace y de cuándo. Sacarlo del atajo y construirlo a mano abre cuatro cosas que de otro modo no existen: reutilizar una definición sin duplicarla, clonarla, reasignarla a otro elemento con la animación en marcha, y modificar los keyframes o la temporización de algo que ya se está moviendo.
- Construir un
KeyframeEffecty unaAnimationpor separado. - Clonar un efecto y reasignar su objetivo.
- Cambiar los keyframes de una animación en curso con
setKeyframes(). - Ajustar la temporización en marcha con
updateTiming()y prever el salto.
Construirlo a mano
const efecto = new KeyframeEffect(
document.querySelector('.tarjeta'),
{ transform: ['scale(1)', 'scale(1.06)'], opacity: [1, 0.9] },
{ duration: 300, easing: 'ease-out', fill: 'both', composite: 'add' }
);
const anim = new Animation(efecto, document.timeline);
anim.play();
Cuatro líneas para lo que animate() hace en una. La diferencia es que ahora efecto y anim son variables independientes: el efecto se puede reutilizar, y la animación se puede construir sin reproducir.
El constructor acepta null como objetivo, lo que da un efecto sin elemento: perfectamente válido, no aplica nada, y sirve como plantilla que se asigna después.
const plantilla = new KeyframeEffect(null, keyframes, opciones);
new Animation(efecto) sin segundo argumento crea una animación sin línea de tiempo. No avanza y reverse() lanza excepción sobre ella. Pasar document.timeline explícitamente es lo habitual.
Clonar y reasignar
El constructor tiene una segunda forma que recibe otro efecto y lo copia:
const original = new KeyframeEffect(null, { opacity: [0, 1] }, { duration: 400 });
for (const el of document.querySelectorAll('.item')) {
const copia = new KeyframeEffect(original);
copia.target = el;
new Animation(copia, document.timeline).play();
}
La copia es profunda en lo que importa: keyframes y temporización quedan duplicados, así que modificar la copia no toca al original. Es la forma de tener una definición única de “cómo entra un elemento” en un módulo y aplicarla en veinte sitios sin repetir el objeto de opciones y sin arriesgarse a que se desincronicen.
target es escribible en cualquier momento, incluso con la animación corriendo. Reasignarlo mueve el efecto de un elemento a otro conservando el tiempo actual: el primero vuelve a su estilo de cascada de golpe y el segundo aparece a mitad de la animación. Es un mecanismo raro pero con un uso concreto, que es transferir un efecto continuo de un elemento a su sustituto cuando el primero va a desaparecer.
// El elemento antiguo se va, el nuevo hereda el movimiento en curso
anim.effect.target = elementoNuevo;
elementoAntiguo.remove();
Modificar un efecto en marcha
Las dos operaciones siguientes actúan sobre un efecto cuya animación ya está corriendo, y las dos tienen la misma trampa: conservan el tiempo actual, que casi nunca es lo que quieres conservar.
Cambiar los keyframes en marcha
setKeyframes() sustituye la lista de keyframes de un efecto sin tocar su temporización ni su tiempo actual. La animación no se reinicia: sigue en el mismo punto de su recorrido, pero produciendo valores nuevos.
const anim = el.animate({ transform: ['translateX(0)', 'translateX(200px)'] },
{ duration: 1000, easing: 'linear', fill: 'both' });
setTimeout(() => {
// A mitad de camino, cambia el destino sin reiniciar
anim.effect.setKeyframes({ transform: ['translateX(0)', 'translateX(600px)'] });
}, 500);
En el instante del cambio el elemento salta, porque el 50% de la nueva animación no está donde estaba el 50% de la anterior. Para que no salte, el keyframe inicial nuevo tiene que ser la posición actual:
setTimeout(() => {
const actual = getComputedStyle(el).transform;
anim.effect.setKeyframes({ transform: [actual, 'translateX(600px)'] });
anim.currentTime = 0;
}, 500);
Leer el valor computado, ponerlo como origen y rebobinar el tiempo. Con eso el elemento continúa suavemente hacia el destino nuevo. Es el patrón que necesita cualquier cosa cuyo destino cambie mientras se mueve: un elemento arrastrable que se suelta, un objeto que persigue al puntero, una barra de progreso cuyo total se actualiza.
Cambiar la temporización en marcha
updateTiming() acepta un objeto parcial y modifica solo las claves que le pases:
anim.effect.updateTiming({ duration: 2000 });
anim.effect.updateTiming({ easing: 'ease-in-out', iterations: 3 });
Aquí hay una trampa que hay que anticipar. updateTiming() conserva el tiempo actual, no el progreso. Si una animación de mil milisegundos va por el 500 —es decir, el 50%— y le pones duration: 2000, el tiempo actual sigue siendo 500 y el progreso pasa a ser el 25%. El elemento retrocede visualmente a la mitad de donde estaba.
Para conservar el progreso hay que recalcular el tiempo a mano:
function cambiarDuracion(anim, nueva) {
const t = anim.effect.getComputedTiming();
const fraccion = anim.currentTime / t.endTime;
anim.effect.updateTiming({ duration: nueva });
const t2 = anim.effect.getComputedTiming();
anim.currentTime = fraccion * t2.endTime;
}
Nótese que la alternativa suele ser mejor: si lo que quieres es que vaya más rápido o más despacio conservando el punto, eso es playbackRate, y playbackRate sí conserva el progreso por construcción. updateTiming() es para cambiar la forma de la línea temporal —el número de iteraciones, la dirección, el relleno—, no la velocidad.
Un mismo KeyframeEffect no se puede compartir entre dos animaciones. Si asignas anim2.effect = anim1.effect, el efecto se transfiere: anim1.effect pasa a valer null y esa animación deja de aplicar nada, quedando viva pero inerte. La relación entre Animation y AnimationEffect es de propiedad exclusiva, no de referencia compartida, y la especificación lo resuelve desasociando en silencio en vez de lanzando una excepción. Es una fuente de bugs desconcertantes cuando alguien construye una fábrica de animaciones que devuelve efectos y los reparte: la última animación que recibe el efecto funciona y todas las anteriores se quedan mudas, sin error, sin cambio de playState y con finished sin resolver. Lo mismo pasa al asignar effect.target: el elemento anterior se queda sin nada. La forma correcta de repartir una definición es clonar con new KeyframeEffect(original) en cada uso, o guardar los keyframes y las opciones como datos planos y construir un efecto nuevo cada vez, que además es más barato de serializar y de inspeccionar.
Leer lo que el motor entendió
Tres métodos de lectura, y los tres valen su peso en depuración:
anim.effect.getKeyframes(); // keyframes normalizados, con computedOffset
anim.effect.getTiming(); // lo que tu pasaste, sin resolver
anim.effect.getComputedTiming(); // resuelto: activeDuration, endTime, progress, currentIteration
getKeyframes() es el que más sorpresas da. Devuelve cada keyframe con offset, computedOffset, easing y composite explícitos, y con los valores de propiedad ya convertidos a cadena. Comparar lo que escribiste con lo que devuelve resuelve la mayoría de las dudas sobre cómo se repartieron los offsets en la sintaxis de objeto o qué curva quedó en cada tramo.
getComputedTiming().progress da la fracción después de aplicar la curva y la dirección, es decir, el progreso visual. Vale null cuando el efecto no está aportando nada. Es lo que hay que leer para sincronizar otro efecto con lo que se está viendo, y no currentTime, que es lineal.
Crea un efecto sin objetivo, clónalo cinco veces y asigna cada clon a un elemento distinto, arrancando las cinco animaciones. Después reasigna el target de la tercera al primer elemento y observa qué le pasa a la animación que ya estaba allí. Por último prueba a asignar el mismo efecto —sin clonar— a dos animaciones y comprueba que la primera se queda con effect a null.