Las promesas del objeto ViewTransition
updateCallbackDone, ready y finished: qué garantiza cada una, cuál esperar para cada tarea, y cómo evitar los rechazos no capturados.
El objeto que devuelve startViewTransition tiene tres promesas y un método. Las tres promesas se resuelven en momentos distintos del ciclo, con garantías distintas y modos de rechazo distintos, y usar la equivocada produce bugs que solo aparecen bajo carga o cuando el usuario pincha rápido. Esta lección es corta en sintaxis y densa en criterio.
- Elegir la promesa correcta para cada tarea posterior a la transición.
- Evitar los rechazos no capturados que ensucian la consola.
- Usar
readypara animar los pseudo-elementos desde JavaScript. - Aplicar
skipTransition()donde corresponde y saber qué no cancela.
Qué garantiza cada una
updateCallbackDone se resuelve cuando tu callback ha terminado y el DOM ya está mutado. No dice nada sobre la parte visual: se resuelve igual si la transición se saltó, si el navegador decidió no animar, o si la pestaña estaba oculta. Es la promesa de “el trabajo real está hecho”.
ready se resuelve cuando el árbol de pseudo-elementos está construido y las animaciones están a punto de arrancar. Es el único instante en que puedes tocar los pseudo-elementos desde JavaScript, porque antes no existen y después de finished ya no existen. Se rechaza si la transición no llega a ocurrir.
finished se resuelve cuando las animaciones han terminado, el árbol se ha desmontado y el documento vuelve a su renderizado normal. Se resuelve incluso si la transición se saltó, siempre que el callback haya funcionado.
La correspondencia con las tareas es directa y merece memorizarse:
| Tarea | Promesa |
|---|---|
| Mover el foco al contenido nuevo | updateCallbackDone |
| Anunciar el cambio a un lector de pantalla | updateCallbackDone |
| Animar los pseudo-elementos con Web Animations | ready |
| Leer la geometría capturada | ready |
Limpiar los view-transition-name temporales |
finished |
| Quitar una clase que solo valía durante la transición | finished |
| Encadenar otra acción visual | finished |
La distinción entre la primera y la última fila es la que más se equivoca. Mover el foco es una tarea de accesibilidad y tiene que ocurrir en cuanto el DOM esté listo, no cuando termine una animación de medio segundo: quien navega con teclado no debería esperar a que se acabe un adorno. Limpiar nombres, en cambio, tiene que esperar a finished, porque quitar un view-transition-name a mitad de animación deja el grupo huérfano.
Los rechazos que ensucian la consola
ready se rechaza cuando la transición no ocurre, y eso pasa en situaciones perfectamente normales: el usuario pincha dos veces seguidas, hay nombres duplicados, la pestaña estaba oculta. Si tienes un await t.ready sin protección, cada uno de esos casos produce un rechazo no capturado en la consola.
const t = document.startViewTransition(mutar);
// MAL: cualquier interrupcion normal ensucia la consola
await t.ready;
animarPseudos();
La forma correcta reconoce que el rechazo de ready es un resultado esperado, no un error:
const t = document.startViewTransition(mutar);
t.ready.then(animarPseudos, () => {
/* La transicion no llego a ocurrir: no hay nada que animar. */
});
O con await, envolviendo:
try {
await t.ready;
animarPseudos();
} catch {
/* Transicion interrumpida: seguimos sin animar. */
}
Un catch vacío suele ser mala práctica, y este es de los pocos casos donde es exactamente lo correcto: el motivo del rechazo no aporta nada porque la reacción es siempre la misma, no hacer nada. Lo que sí conviene es dejar el comentario, para que quien lo lea dentro de un año no lo confunda con un descuido.
finished, en cambio, casi nunca se rechaza: si tu callback funcionó, finished se resuelve. Por eso es la promesa segura para tareas de limpieza, y por eso las limpiezas van ahí y no en ready.
Animar los pseudos desde JavaScript
Hay un caso en que el CSS no basta: cuando la animación depende de valores que solo se conocen en tiempo de ejecución. El ejemplo canónico es una transición que sale del punto donde el usuario hizo clic.
function revelarDesde(x, y, mutacion) {
const t = document.startViewTransition(mutacion);
t.ready.then(
() => {
const radio = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${radio}px at ${x}px ${y}px)`,
],
},
{
duration: 450,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
);
},
() => {}
);
}
Dos detalles que hacen que esto funcione. El primero es pseudoElement, una opción de animate() que permite dirigir la animación a un pseudo-elemento del objetivo; sin ella no habría forma de animar algo que no tiene referencia en el DOM. El segundo es que la animación se lanza sobre document.documentElement, porque el árbol de transición cuelga del elemento raíz.
Una advertencia importante: al animar ::view-transition-new(root) con una animación propia estás añadiendo una animación, no sustituyendo la que aplica el navegador. Si quieres solo tu efecto, hay que desactivar la del navegador en CSS:
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
Sin ese bloque, el desvanecido por defecto se superpone a tu revelado circular y el resultado es un efecto turbio que nadie sabe de dónde sale.
Existe una cuarta pieza que no es promesa y que resuelve el problema por el que la gente busca las promesas: skipTransition(). La confusión habitual es pensar que sirve para cancelar la transición, y no cancela nada de lo que importa: la mutación del DOM ocurre igual, porque el callback ya se ejecutó o se ejecutará de todas formas. Lo único que hace es saltar a la parte visual final. Esa semántica —“el cambio sí, la animación no”— es exactamente la que necesitas en tres sitios y en ninguno de ellos la gente la usa. Uno: prefers-reduced-motion, donde llamar a skipTransition() es más honesto que poner animation: none en CSS, porque expresa la intención en lugar de vaciar las animaciones una a una. Dos: un temporizador de seguridad que la invoque si la transición se alarga más de lo razonable, para que un fallo de red o de fuentes no deje al usuario mirando una imagen congelada. Tres: el botón de “siguiente” pulsado dos veces, aunque aquí el navegador ya hace lo correcto por su cuenta. Y hay un cuarto uso que casi nadie descubre y que es el más elegante: usar la API sin animar en absoluto, envolviendo mutaciones complejas solo para aprovechar el bloqueo de renderizado. Una actualización que toca la cabecera, la lista y el pie a la vez produce, sin transición, un parpadeo de estados intermedios que el usuario percibe como inestabilidad. Envuelta en startViewTransition con las animaciones desactivadas, es atómica. Eso vale más, en muchas interfaces, que cualquier fundido.
La función que conviene tener
Juntando todo, el envoltorio que merece la pena escribir una vez en cada proyecto:
export function conTransicion(mutacion, { reducido = null } = {}) {
if (!document.startViewTransition) {
const r = mutacion();
return { ready: Promise.reject(), finished: Promise.resolve(r) };
}
const t = document.startViewTransition(mutacion);
const prefiereReducir =
reducido ?? matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefiereReducir) t.skipTransition();
t.ready.catch(() => {}); // el rechazo por interrupcion es normal
return t;
}
Centraliza la detección de soporte, la política de movimiento reducido y el silenciado del rechazo esperado, y devuelve un objeto con la misma forma en los dos caminos, de modo que quien la llame no tenga que preguntar si había soporte. Ese último punto es el que hace que el envoltorio se use de verdad: una API de fallback que obliga a ramificar en cada llamada acaba sin usarse.