wandres.dev
VIEW TRANSITIONS II · Nombres, grupos y personalización

Animar cada parte del árbol por separado

Sustituir las animaciones por defecto nodo a nodo: entradas y salidas distintas, direcciones, y las combinaciones que dan resultados profesionales.

⏱ 18 min

Las animaciones que aplica el navegador son un punto de partida deliberadamente neutro. Sustituirlas es CSS ordinario sobre los pseudo-elementos, y la única dificultad real es saber en cuál escribir cada cosa, porque el mismo efecto puede lograrse desde tres nodos distintos con resultados sutilmente diferentes. Esta lección recorre las combinaciones que de verdad se usan.

🎯 Al terminar esta lección sabrás
  • Sustituir las animaciones por defecto sin dejar residuos del fundido.
  • Escribir entradas y salidas distintas usando :only-child.
  • Coordinar la duración de los tres nodos para que el efecto se lea como uno solo.
  • Aplicar direcciones distintas según el sentido de la navegación.

Sustituir sin dejar residuos

Escribir animation-name sobre un pseudo-elemento sustituye lo que hubiera puesto el navegador para ese nodo, y solo para ese nodo. Es lo que quieres, y tiene una consecuencia que se olvida siempre: si sustituyes la animación de old y new pero no la de mezcla, se quedan combinándose.

/* Sustitucion limpia de un fundido cruzado */
::view-transition-old(panel),
::view-transition-new(panel) {
  animation: none;
  mix-blend-mode: normal;
}

Esas dos líneas juntas son el idioma de “quita el fundido de aquí”. animation: none retira el desvanecido; mix-blend-mode: normal retira el modo de mezcla que el navegador aplica para que el fundido no produzca una zona sobreexpuesta en el medio. Sin la segunda, dos imágenes opacas superpuestas se ven turbias y nadie sabe por qué.

Cuando lo que quieres no es quitar sino cambiar, basta con dar tu propia animación:

@keyframes salir-izquierda {
  to { transform: translateX(-24px); opacity: 0; }
}

@keyframes entrar-derecha {
  from { transform: translateX(24px); opacity: 0; }
}

::view-transition-old(panel) {
  animation: salir-izquierda 200ms ease-in both;
}

::view-transition-new(panel) {
  animation: entrar-derecha 260ms 120ms ease-out both;
}

Ese desfase de 120 milisegundos en la entrada es lo que convierte dos animaciones en una secuencia. El ojo lee “lo viejo se va y entonces llega lo nuevo” en lugar de “dos cosas se mueven a la vez”, y la diferencia de sensación es enorme para un cambio que en total dura menos de medio segundo.

Entradas y salidas cuando no hay pareja

Un elemento que solo existe en uno de los dos estados tiene un grupo con un único hijo. La pseudo-clase :only-child es la forma de detectarlo desde CSS y es la pieza que permite escribir entradas y salidas específicas sin que se apliquen también a los morphings.

/* Solo cuando el elemento aparece de la nada */
::view-transition-new(dialogo):only-child {
  animation: emerger 280ms cubic-bezier(0.2, 0, 0, 1) both;
}

/* Solo cuando el elemento desaparece del todo */
::view-transition-old(dialogo):only-child {
  animation: emerger 200ms cubic-bezier(0.4, 0, 1, 1) reverse both;
}

@keyframes emerger {
  from { transform: scale(0.94) translateY(8px); opacity: 0; }
}

Fíjate en el truco de la salida: en lugar de escribir un segundo @keyframes invertido, se reutiliza el mismo con animation-direction: reverse, aquí escrito dentro del atajo. Es menos código y, más importante, garantiza que la entrada y la salida son exactamente simétricas, cosa que dos bloques de keyframes escritos a mano dejan de ser en cuanto alguien toca uno.

Las duraciones sí son distintas a propósito: 280 para entrar y 200 para salir. La asimetría es un principio clásico de la animación de interfaces —las cosas aparecen con más calma de la que desaparecen— y se sostiene aquí igual que en cualquier otro sitio.

Coordinar los tres nodos

Un efecto se lee como uno solo cuando los tres nodos cuentan la misma historia. Las tres combinaciones que cubren casi todo:

Morphing puro, sin fundido. Para cuando los dos estados son visualmente el mismo contenido, solo que colocado de otra forma. El grupo hace todo el trabajo.

::view-transition-group(cabecera) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

::view-transition-old(cabecera),
::view-transition-new(cabecera) {
  animation: none;
  mix-blend-mode: normal;
}

Fundido puro, sin movimiento. Para cuando la caja no cambia y lo que cambia es el contenido.

::view-transition-group(lista) {
  animation: none;
}

::view-transition-old(lista),
::view-transition-new(lista) {
  animation-duration: 220ms;
}

Movimiento con fundido escalonado. El caso general bien hecho: el grupo se mueve durante todo el recorrido, el contenido antiguo se va pronto y el nuevo llega con retraso.

::view-transition-group(foto) {
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.3, 0, 0, 1);
}

::view-transition-old(foto) {
  animation-duration: 160ms;
  animation-timing-function: ease-in;
}

::view-transition-new(foto) {
  animation-duration: 240ms;
  animation-delay: 160ms;
  animation-timing-function: ease-out;
  animation-fill-mode: both;
}

Ese animation-fill-mode: both en el nuevo es imprescindible cuando hay retraso: sin él, durante los primeros 160 milisegundos el pseudo-elemento nuevo se muestra con su valor normal —opaco— y el escalonado no se ve.

La duración del grupo manda sobre todo lo demás, y ese es el número que hay que fijar primero

Hay un orden de trabajo que evita el ciclo interminable de ajustar milisegundos y que casi nadie sigue porque no es evidente. La duración del grupo es la duración de la transición. El árbol se desmonta cuando terminan todas las animaciones, así que la más larga define cuánto dura todo, y como el grupo es el que hace el recorrido espacial, es el que el usuario percibe como “lo que dura el cambio”. Todas las demás duraciones son fracciones de esa. El procedimiento correcto es: primero fija la duración del grupo mirando la distancia que recorre —un cambio pequeño entre 200 y 300 milisegundos, un recorrido que cruza la pantalla entre 400 y 500, y por encima de 600 el usuario ya está esperando—; después reparte los fundidos dentro de esa ventana, con la salida en el primer tercio y la entrada en los dos últimos. Hacerlo al revés —ajustar los fundidos primero y luego alargar el grupo para que quepan— produce transiciones que se sienten lentas aunque cada pieza esté bien. Y un corolario que ahorra mucha discusión con diseño: si una transición “se siente lenta” pero los números son razonables, el problema casi nunca es la duración total, es que la entrada del contenido nuevo empieza demasiado tarde y el usuario pasa media transición mirando una pantalla medio vacía. Adelanta el retraso de new antes de tocar nada más.

Direcciones según el sentido

Un caso que aparece en cuanto hay navegación: la transición debe ir hacia la derecha al avanzar y hacia la izquierda al retroceder. Con lo visto hasta ahora se resuelve con una clase temporal en el elemento raíz:

function navegar(mutacion, sentido) {
  const raiz = document.documentElement;
  raiz.dataset.sentido = sentido;

  const t = document.startViewTransition(mutacion);
  t.finished.finally(() => delete raiz.dataset.sentido);
  return t;
}
[data-sentido='adelante']::view-transition-old(main) {
  animation: salir-izquierda 220ms ease-in both;
}

[data-sentido='adelante']::view-transition-new(main) {
  animation: entrar-derecha 260ms 100ms ease-out both;
}

[data-sentido='atras']::view-transition-old(main) {
  animation: salir-izquierda 220ms ease-in reverse both;
}

[data-sentido='atras']::view-transition-new(main) {
  animation: entrar-derecha 260ms 100ms ease-out reverse both;
}

Funciona, y tiene el defecto de que mezcla un atributo de datos con una preocupación que la propia API modela mejor. Para eso existen los tipos de transición, que hacen lo mismo de forma declarativa y sin ensuciar el DOM: son el contenido de la lección sobre tipos.