Pausar, acelerar y gobernar todo el documento
Los patrones globales que permite el inventario de animaciones, y por qué guardar el estado previo antes de pausar es la diferencia entre una utilidad y un bug.
Que todas las animaciones del documento sean objetos accesibles convierte en trivial una clase de operaciones que antes exigían cooperación de cada librería: detener el movimiento de una sección al abrir un modal, poner la interfaz a cámara lenta para estudiarla, o responder de verdad a una preferencia de accesibilidad que llegó a mitad de la sesión. Los patrones son de tres o cuatro líneas. Lo que separa una utilidad usable de una que rompe cosas es una idea sola: restaurar no es lo contrario de pausar.
- Pausar y reanudar el movimiento de un subárbol sin romper lo que ya estaba pausado.
- Aplicar una velocidad global de depuración de forma persistente.
- Responder a
prefers-reduced-motionen caliente y con el método correcto. - Reconocer los límites del inventario global.
El patrón ingenuo y por qué falla
// NO hagas esto
function pausarTodo() { document.getAnimations().forEach((a) => a.pause()); }
function reanudarTodo() { document.getAnimations().forEach((a) => a.play()); }
pausarTodo() está bien. reanudarTodo() tiene dos fallos graves. El primero es que reanuda animaciones que ya estaban pausadas antes de que tú tocaras nada: un carrusel detenido por :hover, una animación que un componente pausó a propósito, un vídeo decorativo que el usuario había parado. El segundo es peor: play() sobre una animación en estado 'finished' la rebobina y la vuelve a reproducir, así que todas las animaciones terminadas con relleno vuelven a arrancar de golpe. La pantalla se convierte en una feria.
La versión correcta guarda el estado y solo deshace lo que hizo:
const pausadasPorMi = new WeakSet();
function pausar(raiz = document) {
for (const a of raiz.getAnimations({ subtree: true })) {
if (a.playState === 'running') {
a.pause();
pausadasPorMi.add(a);
}
}
}
function reanudar(raiz = document) {
for (const a of raiz.getAnimations({ subtree: true })) {
if (pausadasPorMi.has(a)) {
a.play();
pausadasPorMi.delete(a);
}
}
}
Un WeakSet porque no queremos retener las animaciones en memoria más allá de su vida útil. La comprobación de 'running' para no tocar las pausadas ni las terminadas. Y el borrado al reanudar para que dos ciclos seguidos no se confundan.
Nota que document.getAnimations() no acepta opciones; el { subtree: true } solo aplica a Element. Para que la función sirva con los dos, comprueba el tipo o pásale siempre un elemento.
Pausar una sección
El caso concreto que justifica todo esto es el modal. Cuando se abre un diálogo, el movimiento de fondo distrae y compite por el hilo principal:
const dialogo = document.querySelector('dialog');
const fondo = document.querySelector('main');
function abrirDialogo() {
pausar(fondo);
dialogo.showModal();
}
dialogo.addEventListener('close', () => reanudar(fondo));
El elemento dialog dispara close y cancel, pero no dispara ningún evento al abrirse: por eso la pausa va en la función que llama a showModal() y no en un escuchador.
fondo.getAnimations({ subtree: true }) recoge todo lo que se mueve dentro de main, sean animaciones CSS de una hoja de estilo, transiciones en curso o animaciones creadas por un módulo de gráficos. No hace falta que ninguno de ellos exponga una API de pausa ni saber que existen.
El mismo patrón resuelve el desmontaje de una vista en una aplicación de página única: antes de retirar un subárbol del DOM, cancelar sus animaciones evita que sigan vivas mientras los nodos esperan a la recolección de basura.
function desmontar(raiz) {
for (const a of raiz.getAnimations({ subtree: true })) a.cancel();
raiz.remove();
}
Velocidad global de depuración
function velocidad(v) {
for (const a of document.getAnimations()) a.updatePlaybackRate(v);
}
addEventListener('keydown', (e) => {
if (!e.altKey) return;
if (e.key === '1') velocidad(1);
if (e.key === '2') velocidad(0.25);
if (e.key === '3') velocidad(0.05);
});
updatePlaybackRate() y no la asignación directa, porque el cambio afecta a animaciones que están corriendo y queremos evitar el salto de un frame en las aceleradas por el compositor.
La limitación es que solo alcanza a lo que existe en ese momento. Una animación creada después vuelve a velocidad 1 y rompe la ilusión. Si quieres un modo persistente, guarda el factor y reaplícalo periódicamente:
let factor = 1;
function activarCamaraLenta(v) {
factor = v;
const aplicar = () => {
for (const a of document.getAnimations()) {
if (a.playbackRate !== factor) a.updatePlaybackRate(factor);
}
if (factor !== 1) requestAnimationFrame(aplicar);
};
aplicar();
}
La comprobación de desigualdad antes de aplicar evita reprogramar el cambio en cada frame sobre animaciones que ya van a la velocidad correcta, que además dispararía la resolución de ready continuamente.
Movimiento reducido, y el método correcto
Aquí el inventario da algo que el CSS no: la posibilidad de reaccionar a un cambio de preferencia durante la sesión, y de hacerlo de la forma que deja la interfaz en un estado usable.
const consulta = matchMedia('(prefers-reduced-motion: reduce)');
function aplicarPreferencia() {
if (!consulta.matches) return;
for (const a of document.getAnimations()) {
const t = a.effect?.getComputedTiming();
if (!t) continue;
if (t.iterations === Infinity) a.cancel();
else a.finish();
}
}
consulta.addEventListener('change', aplicarPreferencia);
aplicarPreferencia();
La distinción de las dos ramas es lo importante. Para una animación finita, la respuesta correcta es finish(): la lleva a su estado final de inmediato, que es el estado en el que la interfaz debía quedar. Pausarla la dejaría congelada a mitad, con elementos medio transparentes o medio desplazados, que es peor que la animación original.
Para una animación infinita no hay estado final al que ir —finish() lanzaría excepción—, así que la respuesta es cancelarla y dejar que el elemento vuelva a su estilo de cascada. Una rotación perpetua o un fondo que fluye simplemente desaparecen.
Esto complementa, no sustituye, a la regla CSS de prefers-reduced-motion. La regla declarativa cubre lo que se carga después; el barrido imperativo cubre lo que ya estaba corriendo cuando el usuario cambió la preferencia.
document.getAnimations() recorre el documento completo para construir la lista, y en una página con animaciones abundantes eso no es gratis. Llamarlo dentro de un requestAnimationFrame para “vigilar” el estado del documento es la forma más elegante de convertir una utilidad de depuración en el mayor consumidor de tiempo del hilo principal, precisamente en la página que estabas intentando optimizar. El coste crece con el número de elementos con animaciones, no con el número de animaciones, porque hay que visitar el árbol. La medida es sencilla y conviene hacerla antes de dejar cualquier vigilancia puesta: envuelve la llamada entre dos performance.now() en la página real y mira el número. Si pasa de medio milisegundo, ya te has comido un tres por ciento del presupuesto de un frame de 60Hz sin haber animado nada. Los patrones de esta lección están escritos para dispararse en respuesta a eventos —abrir un modal, pulsar un atajo, cambiar una preferencia— y no en bucle, y esa no es una elección estilística. El único de ellos que corre por frame, la cámara lenta persistente, es también el único que solo debería existir en desarrollo.
Los límites
Tres cosas que el inventario no alcanza, y conviene saberlas antes de construir sobre él.
Los documentos anidados quedan fuera: un iframe tiene su propia línea de tiempo y su propio inventario, y solo se llega a él con frame.contentDocument y siempre que sea del mismo origen. Cualquier incrustación de terceros —un vídeo, un mapa, un anuncio— es territorio inaccesible.
Las animaciones que no usen la plataforma tampoco aparecen. Una librería que dibuje en un canvas con su propio bucle, o que escriba estilos en cada frame desde requestAnimationFrame, no crea objetos Animation y no está en la lista. Las librerías modernas que se apoyan en WAAPI sí aparecen, y esa es una buena razón para preferirlas.
Y los vídeos y los GIF animados no son animaciones en este sentido. Pausar el movimiento de una página con movimiento reducido tiene que incluir un barrido de video[autoplay] aparte, porque el inventario no los conoce.
Implementa pausar y reanudar con el WeakSet y pruébalos en una página que tenga un carrusel pausado por :hover y varias animaciones con fill: forwards ya terminadas. Comprueba que la versión ingenua reanuda el carrusel y relanza las terminadas, y que la correcta no toca ninguna de las dos.