wandres.dev
VIEW TRANSITIONS I · Mismo documento

Qué captura el navegador exactamente

El mapa de bits del estado antiguo frente a la captura viva del nuevo, el bloque contenedor de instantáneas, y los elementos que no se pueden capturar.

⏱ 18 min

Las dos capturas de una transición de vista no son simétricas, y esa asimetría explica la mitad de los comportamientos que parecen bugs. Lo viejo es un mapa de bits congelado; lo nuevo es el elemento vivo. Junto con eso hay un rectángulo de referencia que no es el viewport y una lista corta de elementos que sencillamente no se pueden capturar. Con esas tres cosas claras, las transiciones dejan de tener sorpresas.

🎯 Al terminar esta lección sabrás
  • Distinguir la captura estática de la captura viva y sus consecuencias visibles.
  • Definir el bloque contenedor de instantáneas y por qué no es el viewport.
  • Reconocer los tres casos en que view-transition-name no tiene efecto.
  • Estimar el coste de una transición con muchos elementos nombrados.

Un mapa de bits y un elemento vivo

La especificación lo dice sin ambigüedad: la transición se hace con una captura visual estática del estado antiguo y una captura viva del estado nuevo. Internamente, el estado antiguo de cada elemento capturado se guarda como un mapa de bits bidimensional. El estado nuevo no: es el elemento real, renderizado en la capa de transición.

De ahí salen consecuencias muy concretas y muy visibles.

El contenido antiguo no responde a nada. Un vídeo capturado como estado antiguo se queda en el fotograma exacto de la captura durante toda la transición. Un GIF animado se congela. Un elemento con su propia animación CSS deja de moverse. No hay forma de evitarlo: no hay elemento, hay una foto.

El contenido nuevo sigue vivo. Un vídeo en el estado nuevo sigue reproduciéndose mientras se desvanece hacia dentro. Una animación CSS del elemento nuevo sigue corriendo. Esto es normalmente lo que quieres, y ocasionalmente lo que no: si el elemento nuevo tiene una animación de entrada propia, se va a superponer a la de la transición.

Escalar el estado antiguo lo pixela. Si el grupo crece —una miniatura que se convierte en imagen grande— la captura antigua se estira como se estiraría cualquier imagen, y durante la transición se ve borrosa mientras la nueva, que es nítida, aparece por encima. El fundido cruzado por defecto oculta bien ese artefacto; una transición sin fundido, no. Es el motivo de que “morphing sin fundido” se vea mal en transiciones que cambian mucho de tamaño.

El estado antiguo no se puede seleccionar ni leer. Durante la transición, el texto que ves de la captura antigua no es texto: es una imagen. No se puede seleccionar, no lo lee un lector de pantalla y no lo encuentra la búsqueda del navegador. Como la transición dura un cuarto de segundo, no es un problema de accesibilidad en la práctica, pero sí es la razón por la que una transición muy larga sí lo sería.

El bloque contenedor de instantáneas

Los pseudo-elementos no se colocan respecto al bloque contenedor inicial del documento, sino respecto a un rectángulo distinto que la especificación llama bloque contenedor de instantáneas. Está definido como el rectángulo que cubre todas las áreas de la ventana que podrían mostrar contenido de página, y por tanto es consistente independientemente de las barras de scroll de la raíz y de los widgets interactivos.

Traducido a los dos casos reales:

  • En escritorio, incluye el área de contenido y las barras de scroll, pero no la barra de direcciones, porque ahí nunca aparece contenido.
  • En móvil, incluye la barra de direcciones, porque puede desaparecer al scrollear y entonces esa franja sí muestra contenido. También incluye el área del teclado virtual, por el mismo motivo.

El diseño está pensado para que el rectángulo de referencia no cambie entre la captura antigua y la nueva. Si dependiera del viewport visible, una transición que ocurra justo cuando la barra de direcciones se retrae mediría dos rectángulos distintos y todo saldría desplazado.

La consecuencia práctica que más se nota: dentro de la transición, ese rectángulo es a la vez el bloque contenedor para posicionamiento absoluto y para posicionamiento fijo de ::view-transition y sus descendientes. Es decir, un position: fixed dentro de la transición se posiciona respecto a él y no respecto al viewport. Y las coordenadas que veas en el inspector para los grupos están medidas desde su esquina superior izquierda, que en móvil no coincide con la esquina de tu contenido.

Cuándo el nombre no tiene efecto

view-transition-name es una propiedad que puede quedarse sin hacer nada, y el motivo no es un error de sintaxis. La especificación establece tres condiciones bajo las cuales la propiedad no tiene efecto:

La caja principal está fragmentada. Es el caso del elemento en línea que se parte en varias líneas, o de un elemento partido entre dos columnas de un layout multicolumna. No hay una caja única que capturar, así que no se captura. Si necesitas capturar un trozo de texto en línea, envuélvelo en algo que genere una caja de bloque.

La caja se salta su contenido. Ocurre con content-visibility: hidden y con la contención de tamaño y layout que hace que un subárbol no se renderice. Es la trampa clásica del contenido virtualizado: los elementos fuera de pantalla de una lista larga con content-visibility: auto no se pueden capturar.

El elemento no está renderizado. display: none, un ancestro con display: none, o cualquier otra situación en la que el elemento no genera caja.

Ninguno de los tres produce un error. La propiedad simplemente no hace nada, el elemento no obtiene su propio grupo, y forma parte de la captura general de la raíz. El síntoma es que “ese elemento no hace morphing y no entiendo por qué”.

Cada nombre es un mapa de bits, y los mapas de bits ocupan memoria de GPU

La pregunta que nadie hace hasta que la interfaz va a tirones en un portátil integrado: ¿cuánto cuesta una transición de vista? El modelo mental correcto es el de las capas del compositor, y la aritmética es la misma. Cada elemento con view-transition-name produce al menos un mapa de bits del tamaño de su caja de borde, subido a la GPU, más una representación viva del elemento nuevo, más los pseudo-elementos que los envuelven. Una transición con seis nombres es manejable. Una transición con ochenta —el caso típico de “he puesto view-transition-name a cada tarjeta de la rejilla para que se reordenen bonito”— reserva ochenta texturas simultáneas y ochenta grupos animándose a la vez, y en un dispositivo con memoria de vídeo compartida eso se nota como una caída de frames justo en el momento de la animación. Hay tres tácticas para no llegar ahí. La primera es no nombrar lo que no cambia: si un elemento aparece igual en los dos estados y en el mismo sitio, el fundido de la raíz ya lo cubre y nombrarlo solo añade coste. La segunda es nombrar por grupos y no por instancia: en una rejilla que se reordena, muchas veces basta con nombrar el contenedor y dejar que el fundido resuelva el interior. La tercera, y la que más gente descubre tarde: nombra solo los elementos visibles. Si la lista tiene doscientos elementos y en pantalla caben ocho, asigna el nombre solo a los que están en el viewport y quítalo después; el resto no aporta nada porque el usuario no los está mirando. Ese último ajuste convierte transiciones injugables en transiciones instantáneas, y no requiere cambiar nada del CSS.

Un patrón de coste controlado

Aplicando lo anterior, así queda una transición sobre una lista larga que se reordena, con los nombres asignados solo a lo que se ve:

function reordenar(lista, comparador) {
  const visibles = [...lista.children].filter((el) => {
    const r = el.getBoundingClientRect();
    return r.bottom > 0 && r.top < innerHeight;
  });

  visibles.forEach((el, i) => {
    el.style.viewTransitionName = `fila-${i}`;
  });

  const t = document.startViewTransition(() => {
    [...lista.children]
      .sort(comparador)
      .forEach((el) => lista.appendChild(el));
  });

  t.finished.finally(() => {
    visibles.forEach((el) => {
      el.style.viewTransitionName = '';
    });
  });

  return t;
}

Tres decisiones que merecen justificarse. Los nombres se asignan antes de la llamada, para que existan en la captura del estado antiguo. Se filtran por visibilidad con una sola lectura de geometría por elemento, hecha fuera de la transición para no forzar layout en el momento malo. Y se limpian en finished con finally, para que la limpieza ocurra también si la transición se interrumpe: dejar nombres colgando es exactamente lo que provoca el error de unicidad la próxima vez.

Ese error —dos elementos con el mismo nombre a la vez— es la causa número uno de transiciones que no ocurren, y merece la primera lección del nivel siguiente para él solo.