wandres.dev
VIEW TRANSITIONS III · Entre documentos

Los eventos pageswap y pagereveal

Los dos puntos de intervención en una transición entre documentos, el objeto de activación con las URLs implicadas, y qué se puede hacer en cada uno.

⏱ 18 min

La regla @view-transition activa la transición y no deja ningún hueco para decidir nada. Los dos eventos que la acompañan son ese hueco: pageswap se dispara en el documento que se abandona, justo antes de que desaparezca, y pagereveal en el que llega, justo antes de que se pinte por primera vez. Entre los dos cubren todo lo que hace falta para que una transición entre documentos sea algo más que un fundido.

🎯 Al terminar esta lección sabrás
  • Colocar código en pageswap y en pagereveal sabiendo qué está disponible en cada uno.
  • Leer las URLs de origen y destino desde el objeto de activación.
  • Modificar los tipos de la transición en función del destino.
  • Reconocer cuándo cada evento se dispara sin transición asociada.

Dos eventos, dos documentos

pageswap se dispara en window del documento saliente, justo antes de que el navegador lo sustituya. En ese momento el DOM antiguo todavía está entero y todavía puedes tocarlo: es la última oportunidad de asignar view-transition-name a lo que quieras que se empareje.

pagereveal se dispara en window del documento entrante, después del primer cálculo de estilo y layout pero antes del primer pintado. El DOM nuevo ya existe, ya está maquetado, y todavía no se ha visto. Es donde se preparan los nombres del destino.

Los dos eventos llevan una propiedad viewTransition con el objeto ViewTransition de la transición en curso, que es el mismo tipo que devuelve startViewTransition: tiene ready, finished, updateCallbackDone, types y skipTransition().

// En la pagina que se abandona
window.addEventListener('pageswap', (e) => {
  if (!e.viewTransition) return;   // no hay transicion: nada que hacer
  // ... preparar el estado antiguo
});

// En la pagina que llega
window.addEventListener('pagereveal', (e) => {
  if (!e.viewTransition) return;
  // ... preparar el estado nuevo
});

Esa comprobación de la primera línea no es defensiva por costumbre: los dos eventos se disparan siempre, haya transición o no. pagereveal se dispara en cualquier carga de página, incluida la primera visita, y en ese caso viewTransition es null. pageswap se dispara en cualquier navegación saliente, y también vale null cuando la navegación no cumple las condiciones. Un código que asuma la existencia del objeto falla en la primera visita de cada usuario, que es el peor sitio donde fallar.

Su disponibilidad va de la mano de las transiciones entre documentos: Chromium desde la 123 y 124 respectivamente, con la propiedad viewTransition desde la 126, y Safari desde la 18.2. Firefox no los implementa.

El objeto de activación

pageswap lleva además una propiedad activation con información sobre la navegación que está a punto de ocurrir. Es lo que permite decidir en función del destino.

window.addEventListener('pageswap', (e) => {
  if (!e.viewTransition) return;

  const desde = e.activation.from?.url;      // entrada actual del historial
  const hasta = e.activation.entry.url;      // entrada de destino
  const tipo  = e.activation.navigationType; // push, replace, traverse o reload

  console.log(tipo, desde, '->', hasta);
});

Las tres piezas son las que necesitas para todo lo demás. from es la entrada del historial que se abandona y puede ser null si no había ninguna. entry es la entrada de destino. navigationType distingue una navegación nueva de un retroceso, que es la información clave para decidir el sentido de la animación.

En el documento entrante, la información equivalente se obtiene de la API de navegación, sin necesidad de un objeto propio en el evento:

window.addEventListener('pagereveal', (e) => {
  if (!e.viewTransition) return;

  const desde = navigation.activation.from?.url;
  const tipo  = navigation.activation.navigationType;
});

La misma forma, el mismo tipo de datos. La simetría es deliberada: los dos documentos pueden razonar sobre la misma navegación con la misma información.

Decidir los tipos según el destino

El uso más rentable de los dos eventos es asignar tipos en función de a dónde se va, que es algo que el descriptor types de la regla CSS no puede hacer porque es estático.

const ORDEN = ['/', '/blog/', '/proyectos/', '/sobre-mi/'];

function sentido(desdeUrl, hastaUrl) {
  const i = ORDEN.indexOf(new URL(desdeUrl).pathname);
  const j = ORDEN.indexOf(new URL(hastaUrl).pathname);
  if (i === -1 || j === -1) return 'salto';
  return j > i ? 'adelante' : 'atras';
}

window.addEventListener('pageswap', (e) => {
  if (!e.viewTransition || !e.activation.from) return;
  e.viewTransition.types.add(
    sentido(e.activation.from.url, e.activation.entry.url)
  );
});

window.addEventListener('pagereveal', (e) => {
  if (!e.viewTransition || !navigation.activation.from) return;
  e.viewTransition.types.add(
    sentido(navigation.activation.from.url, location.href)
  );
});

Fíjate en que el cálculo se hace en los dos documentos, porque cada uno tiene su propio objeto de transición y su propio conjunto de tipos. Es la parte que más sorprende de trabajar entre documentos: no hay un único objeto compartido, hay dos vistas del mismo evento, una en cada lado.

Con eso, el CSS de la lección de tipos funciona sin cambios, porque :active-view-transition-type() no distingue de dónde vinieron los tipos.

pagereveal ocurre antes del primer pintado, y eso lo convierte en el sitio más peligroso del ciclo

Hay una propiedad de pagereveal que es a la vez su mayor virtud y su mayor riesgo: se dispara después del layout y antes del primer pintado, lo que significa que cualquier cosa que hagas ahí se ve reflejada sin que el usuario haya visto nunca el estado anterior. Es exactamente lo que quieres para asignar nombres o ajustar el DOM antes de la captura. Y es exactamente lo que no quieres para nada más, porque estás en la ruta crítica del primer frame de una página nueva. Tres reglas que evitan convertir una mejora en un problema de rendimiento. Primera: nada de red, nunca. Un fetch en pagereveal retrasa el primer pintado de la página entera, y como el navegador ya está esperando a que el documento esté listo para empezar la transición, ese retraso se acumula con el que ya había. Segunda: nada de lecturas de geometría en bucle. Un getBoundingClientRect() por cada elemento de una lista de doscientos, dentro de pagereveal, es medio segundo de trabajo síncrono antes de que se vea nada. Si necesitas geometría, léela una vez y en lote. Tercera, y la que más gente incumple: no metas ahí la inicialización de tu aplicación. pagereveal se dispara en cada carga de página, no solo en las que transicionan, y es tentador usarlo como “el sitio donde el DOM ya está listo”. Para eso ya existe el evento de contenido cargado, que no bloquea el primer pintado. pagereveal es para la transición y para nada más. Si te descubres escribiendo ahí algo que no menciona viewTransition, está en el sitio equivocado.

Un detalle sobre el scroll

Hay un comportamiento que confunde a todo el mundo la primera vez y que conviene anticipar: en una navegación con transición, el navegador restaura la posición de scroll antes de capturar el estado nuevo. Es decir, la captura del documento entrante se hace en la posición de scroll que va a tener, no arriba del todo.

Eso es lo correcto —si vuelves atrás a media página, la transición debe llevar hasta ahí— y tiene una consecuencia práctica: si tu página cambia de altura después de pagereveal porque carga fuentes o imágenes sin dimensiones reservadas, la posición restaurada será la equivocada y la transición terminará en un sitio distinto del que empieza el contenido. El arreglo no está en la transición: está en reservar espacio para las imágenes y en cargar las fuentes con métricas ajustadas. Es otro caso donde una transición mal vista es el síntoma de un problema de layout que ya estaba ahí.