play, pause, reverse, finish, cancel y el estado
Los cinco métodos de reproducción, la máquina de estados que forman, y las tres transiciones que no hacen lo que su nombre sugiere.
Los cinco métodos de reproducción de Animation tienen nombres transparentes y comportamientos que no lo son. play() sobre una animación terminada la rebobina; reverse() sobre una animación que aún no ha empezado la coloca al final; finish() lanza una excepción en dos situaciones perfectamente razonables. Ninguna de esas conductas es arbitraria: todas salen de que la animación es una máquina de estados con reglas explícitas, y conocerlas evita reescribir a mano lo que la API ya hace.
- Enumerar los cuatro valores de
playStatey las transiciones entre ellos. - Explicar el rebobinado automático de
play()y dereverse(). - Distinguir
finish()decancel()en lo que le queda al elemento. - Detectar el estado pendiente con
pendingy saber por qué existe.
La máquina de estados
flowchart LR I[idle] -->|play| R[running] R -->|pause| P[paused] P -->|play| R R -->|llega al final| F[finished] R -->|finish| F F -->|play| R R -->|cancel| I P -->|cancel| I F -->|cancel| I style I fill:#f9e2af,color:#11111b style R fill:#a6e3a1,color:#11111b style P fill:#89b4fa,color:#11111b style F fill:#cba6f7,color:#11111b
anim.playState devuelve una de esas cuatro cadenas. 'idle' significa que la animación no tiene tiempo actual y no aplica ningún valor al elemento: es como si no existiera. 'running' es la reproducción normal. 'paused' conserva el tiempo actual congelado. 'finished' significa que la reproducción alcanzó su límite; el efecto sigue aplicándose si hay relleno.
La diferencia entre 'idle' y 'finished' es la que más importa en la práctica: en 'idle' el elemento vuelve a su estilo de cascada, en 'finished' puede seguir mostrando el valor final si fill lo indica.
Una animación creada con animate() empieza en 'running', aunque técnicamente esté pendiente durante uno o dos frames. Una creada con new Animation(...) empieza en 'idle' hasta que llames a play().
play y reverse
Los dos métodos de arranque comparten una propiedad que sus nombres no anuncian: ninguno se limita a poner en marcha lo que hay, los dos reposicionan la animación si hace falta para que la orden tenga sentido.
play rebobina
play() no significa “continúa”. Significa “asegúrate de que esta animación está reproduciéndose”, y para cumplirlo puede tener que rebobinar:
- Desde
'idle': arranca desde el principio. - Desde
'paused': continúa desde donde estaba. - Desde
'finished'con velocidad positiva: salta al inicio y vuelve a reproducir. - Desde
'finished'con velocidad negativa: salta al final y reproduce hacia atrás.
Ese tercer caso es el rebobinado automático, y es lo que hace que reiniciar una animación sea una sola llamada:
boton.addEventListener('click', () => anim.play()); // vuelve a empezar cada vez
Con una animación CSS reiniciar exige quitar la clase, forzar un reflujo y volver a ponerla. Aquí es play(). Y como las animaciones CSS también son objetos Animation, play() funciona igual sobre ellas.
Si lo que querías era continuar sin rebobinar, comprueba el estado antes:
if (anim.playState !== 'finished') anim.play();
reverse invierte y arranca
reverse() hace dos cosas: cambia el signo de playbackRate y llama a play(). La combinación produce el comportamiento útil en todos los casos:
const menu = panel.animate(
{ transform: ['translateX(-100%)', 'translateX(0)'] },
{ duration: 280, fill: 'both' }
);
boton.addEventListener('click', () => menu.reverse());
Ese código abre y cierra el panel alternativamente, y lo hace conservando el progreso: si pulsas a mitad de la apertura, se cierra desde donde estaba, no desde el final. Es la propiedad que hace que la interfaz se sienta receptiva, y escribirla a mano con CSS exige medir el progreso actual, recalcular una duración proporcional y lanzar la animación inversa.
El caso raro es reverse() sobre una animación en 'idle' con velocidad positiva. Como no ha empezado, invertirla significaría reproducir hacia atrás desde el tiempo cero, es decir, no hacer nada. La especificación resuelve colocando el tiempo actual al final y reproduciendo desde ahí hacia atrás. Es lo que quieres el noventa y nueve por ciento de las veces, pero conviene saberlo si te encuentras una animación que “empieza por el final”.
reverse() lanza InvalidStateError si la animación no tiene línea de tiempo asociada, que solo ocurre si la creaste con new Animation(effect, null).
finish y cancel dejan el elemento en sitios distintos
finish() lleva el tiempo actual al final —o al principio, si la velocidad es negativa—, pasa a 'finished' y dispara el evento finish. El efecto termina de forma ordenada: si hay fill: forwards, el valor final se queda aplicado.
cancel() pasa a 'idle', pone currentTime a null y retira el efecto. El elemento vuelve inmediatamente a su estilo de cascada, aunque hubiera relleno. Además rechaza la promesa finished con un AbortError y dispara el evento cancel.
const a = el.animate({ opacity: [1, 0] }, { duration: 500, fill: 'forwards' });
setTimeout(() => a.finish(), 100); // el elemento se queda invisible
// setTimeout(() => a.cancel(), 100); // el elemento vuelve a ser visible de golpe
Elegir mal entre las dos produce dos bugs opuestos. Usar cancel() para “terminar antes” hace que el elemento pegue un salto al estado original. Usar finish() para “quitar la animación” deja el relleno bloqueando la cascada para siempre.
finish() lanza InvalidStateError en dos situaciones. La primera, si playbackRate es cero: no hay una dirección hacia la que terminar. La segunda, si la velocidad es positiva y el tiempo final es infinito, es decir, con iterations: Infinity: no hay final al que ir. Para terminar una animación infinita hay que cancelarla, o darle un número finito de iteraciones antes.
Existe un quinto estado que no aparece en playState y que explica los null desconcertantes: el estado pendiente. anim.pending es true mientras hay una operación de reproducción o de pausa esperando a resolverse, y durante ese intervalo anim.startTime vale null aunque playState ya diga 'running'. La razón es el compositor: cuando el navegador puede acelerar la animación fuera del hilo principal, tiene que negociar con el proceso de composición para acordar el instante exacto de inicio, y hasta que esa negociación termina el tiempo de inicio no existe. Es lo correcto: si el motor inventara un startTime provisional, la animación empezaría un frame antes o después de lo que dice, y una secuencia de varias animaciones se desincronizaría. La consecuencia práctica es que startTime justo después de animate() es siempre null, y que el patrón para leerlo es await anim.ready. Y hay una trampa: pause() sobre una animación pendiente de arrancar deja la operación de pausa también pendiente, así que hay ventanas de uno o dos frames en las que playState dice 'paused' pero la animación todavía no se ha detenido visualmente. Si estás sincronizando algo con precisión de frame, espera siempre a ready.
El patrón de alternancia robusto
Juntando todo, el control de un panel que se abre y se cierra sin saltos, sin duplicar animaciones y sin estado externo:
function crearPanel(panel) {
const anim = panel.animate(
{ transform: ['translateX(-100%)', 'translateX(0)'], opacity: [0, 1] },
{ duration: 280, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'both' }
);
anim.pause();
anim.currentTime = 0;
return {
abrir() { anim.playbackRate = 1; anim.play(); return anim.finished; },
cerrar() { anim.playbackRate = -1; anim.play(); return anim.finished; },
alternar() { anim.reverse(); return anim.finished; },
get abierto() { return anim.playbackRate > 0 && anim.playState === 'finished'; },
};
}
Una sola animación para los dos sentidos, creada una vez y pausada en el instante cero. abrir() y cerrar() fijan la dirección de forma explícita, alternar() deja que reverse() decida. El estado de apertura no se guarda en ninguna variable: se deduce de la propia animación, que es la única fuente de verdad que no puede desincronizarse.
Monta el panel de arriba y prueba a pulsar el botón de alternar cinco veces muy rápido. Comprueba que nunca hay un salto y que el panel siempre acaba en un extremo. Después cambia anim.reverse() por anim.cancel() seguido de una animación nueva en el sentido contrario y observa la diferencia: los saltos aparecen porque cada animación nueva empieza desde el extremo, no desde donde estaba.