wandres.dev
TRANSITIONS · La animación de estado

El modelo de transición: el disparador es un cambio de estilo

Qué es exactamente un evento de cambio de estilo, cómo se comparan el estado anterior y el posterior, y qué ocurre cuando interrumpes una transición a mitad.

⏱ 18 min

Una transición no es una animación que tú lanzas: es una regla que dice cómo reaccionar cuando un valor cambia. Esa diferencia parece semántica y no lo es en absoluto, porque de ella se derivan las tres cosas que confunden a todo el mundo: que una transición no se dispare aunque el valor sea distinto, que se dispare cuando no querías, y que al interrumpirla no tarde lo que dice el CSS en volver. Las tres tienen una explicación exacta en el modelo, y todas se entienden desde un concepto: el evento de cambio de estilo.

🎯 Al terminar esta lección sabrás
  • Definir qué es un evento de cambio de estilo y cuándo ocurre.
  • Explicar qué compara el motor para decidir si arranca una transición.
  • Predecir el comportamiento de una transición interrumpida y revertida.
  • Usar los cuatro eventos del ciclo de una transición con el significado correcto.

El evento de cambio de estilo

El motor no calcula estilo cada vez que tocas una clase: agrupa los cambios y calcula cuando lo necesita. Cada una de esas ocasiones es un evento de cambio de estilo, y ocurren fundamentalmente en dos momentos:

  • Cuando el navegador va a actualizar el renderizado, es decir antes de pintar un fotograma, después de ejecutar los callbacks de requestAnimationFrame.
  • Cuando tu código pide un valor que exige tener el estilo al día: getComputedStyle(el).algo, o cualquier lectura de geometría.

En cada evento de cambio de estilo, para cada elemento y cada propiedad, el motor tiene dos valores: el estilo anterior al cambio, que es el que la propiedad tenía en el evento anterior, y el estilo posterior al cambio, el que acaba de computar. Si esos dos difieren, la propiedad está en la lista de transition-property y la duración es mayor que cero, arranca una transición.

Toda la lección se sigue de ahí. Y el corolario más importante también: si entre dos estados no ha ocurrido ningún evento de cambio de estilo, para el motor no ha habido cambio. Ese es el mecanismo que explica la transición que no dispara, y tiene una lección entera dedicada al final de este nivel.

El estilo anterior al cambio incluye un matiz que importa: no es el valor de la hoja de estilos, es el valor actual, con las transiciones y animaciones en curso ya evaluadas al instante presente. Por eso interrumpir una transición no arranca desde el destino anterior sino desde donde el elemento estaba realmente.

.caja {
  translate: 0 0;
  transition: translate 600ms ease;
}
.caja.movida { translate: 300px 0; }

Si añades la clase, esperas 300 milisegundos y la quitas, el elemento no salta a 300 píxeles para volver desde ahí: arranca desde donde estuviera en ese instante. Eso es lo que hace que las transiciones sean tan robustas frente a cambios de estado rápidos y por lo que son la familia con menos estado propio.

La reversión no tarda lo que pone en el CSS

Aquí hay un comportamiento definido en la especificación que casi nadie conoce y que explica una observación común: al interrumpir y revertir, la vuelta parece más rápida de lo que debería.

No lo parece: lo es. Cuando una transición se interrumpe y el nuevo destino es el valor de partida de la que estaba corriendo, el motor detecta que se trata de una reversión y aplica un factor de acortamiento: la nueva transición dura la fracción del recorrido que queda por deshacer, no la duración completa.

Con una transición de 600 milisegundos interrumpida al 30 por ciento, la vuelta dura aproximadamente 180, no 600. Es exactamente lo que corresponde físicamente: has recorrido el 30 por ciento, tienes que deshacer el 30 por ciento, y a la misma velocidad se tarda el 30 por ciento del tiempo.

Ese detalle es la razón de que un menú que se abre y se cierra rápido con transiciones se sienta natural sin que nadie haya escrito una línea para gestionarlo, y de que la misma interacción implementada a mano con WAAPI se sienta mal si no reproduces el cálculo. La reversión es una de las cosas que la familia declarativa te da gratis.

Conviene saber también cuándo no aplica: solo cuando el nuevo destino es el valor de partida de la transición en curso. Si interrumpes a mitad y mandas el elemento a un tercer valor, la nueva transición dura lo que dice el CSS.

Los cuatro eventos

Una transición emite cuatro eventos, y la diferencia entre los dos primeros es la que se confunde siempre.

Evento Cuándo
transitionrun Se crea la transición, antes del retraso
transitionstart Empieza la interpolación, después del retraso
transitionend Termina de forma natural
transitioncancel Se interrumpe antes de terminar

Si tu código quiere saber “ya está en marcha algo”, el evento correcto es transitionrun. Si quiere saber “el elemento ya se está moviendo”, es transitionstart. Con transition-delay: 0s los dos ocurren casi a la vez y la diferencia no se nota, lo que hace que el error solo aparezca el día que alguien añade un retraso.

Los dos finales son los que producen bugs de estado. transitionend no se emite si la transición se cancela, y una transición se cancela por causas que no controlas: el elemento se oculta, se le quita la clase, otra regla cambia el valor de destino. Cualquier lógica que espere transitionend para liberar un bloqueo o para eliminar un nodo tiene que escuchar también transitioncancel.

Y hay un tercer detalle que rompe código en producción: los eventos de transición burbujean, así que un manejador puesto en un contenedor recibe los de todos sus descendientes. Y se emite uno por propiedad: una transición de tres propiedades emite tres transitionend. La combinación de las dos cosas produce el bug clásico de un panel que se cierra tres veces o que se cierra cuando lo que terminó fue una transición de un botón interno.

// Esperar el final de una transicion concreta, con las tres defensas.
function finDeTransicion(el, propiedad) {
  return new Promise((resolve) => {
    function alTerminar(e) {
      if (e.target !== el) return;                 // no de un descendiente
      if (e.propertyName !== propiedad) return;    // no de otra propiedad
      limpiar();
      resolve('fin');
    }
    function alCancelar(e) {
      if (e.target !== el || e.propertyName !== propiedad) return;
      limpiar();
      resolve('cancelada');
    }
    function limpiar() {
      el.removeEventListener('transitionend', alTerminar);
      el.removeEventListener('transitioncancel', alCancelar);
    }
    el.addEventListener('transitionend', alTerminar);
    el.addEventListener('transitioncancel', alCancelar);
  });
}

Aun con las tres defensas, hay un caso que esa función no cubre: si la transición nunca llega a arrancar, ninguno de los dos eventos se emite y la promesa no se resuelve jamás. Por eso, cuando el flujo de la interfaz depende del final de una animación, la alternativa robusta es no usar eventos de transición sino la promesa finished del objeto Animation, que sí se rechaza al cancelarse y sí se puede consultar.

// Equivalente robusto usando el modelo de animacion.
const [transicion] = el.getAnimations().filter(
  (a) => a.transitionProperty === 'translate'
);
await transicion?.finished.catch(() => {});
La transición no se dispara en la hoja de estilos: se dispara en el elemento, y por eso su sitio importa

Hay un error de colocación que produce un comportamiento asimétrico desconcertante y que casi todo el mundo comete al menos una vez: escribir la declaración transition dentro de la regla del estado en lugar de en la regla base. Si pones transition: translate 200ms dentro de .caja:hover, la transición existe mientras el puntero está encima y deja de existir en cuanto se va. El resultado es que la entrada se anima y la salida es instantánea, y la causa no es que “el hover funcione distinto”, es que en el evento de cambio de estilo en que el puntero abandona el elemento, el estilo posterior al cambio ya no tiene ninguna transition declarada, así que no hay nada que interpolar. La transición se evalúa con el estilo de destino, no con el de origen. Esto tiene dos consecuencias que sí son útiles y que se pueden explotar a propósito. La primera: puedes darle a la entrada y a la salida curvas y duraciones distintas sin JavaScript, declarando la transición en la regla base para la salida y sobreescribiéndola en la regla del estado para la entrada. Es la forma correcta de que un panel entre en 260 milisegundos con una curva de salida y se vaya en 180 con una de entrada, que es lo que el nivel de principios recomienda. La segunda, menos conocida: puedes desactivar deliberadamente la transición en una dirección poniendo transition: none en el estado de destino, lo que sirve para que algo entre animado y desaparezca de golpe, que a veces es exactamente lo que quieres.

/* Salida: rapida y con curva de entrada. Declarada en la regla base. */
.panel {
  translate: 0 -8px;
  opacity: 0;
  transition:
    translate 180ms cubic-bezier(0.4, 0, 1, 1),
    opacity 140ms linear;
}

/* Entrada: mas larga y con curva de salida. Declarada en el estado. */
.panel[data-abierto] {
  translate: 0 0;
  opacity: 1;
  transition:
    translate 260ms cubic-bezier(0.2, 0, 0, 1),
    opacity 200ms linear;
}

Qué no puede disparar una transición

Cerrar el modelo con la lista de lo que nunca arranca una transición evita una buena parte de las horas perdidas.

Un cambio que no produce un valor computado distinto no dispara nada: pasar de 1em a 16px en un contexto donde ambos computan a lo mismo no es un cambio. Un cambio en una propiedad no interpolable produce un salto, no una transición, salvo que se habilite explícitamente el comportamiento discreto, que es tema del nivel 6. Un cambio en un elemento que no se está renderizando no dispara nada. Y un cambio simultáneo con la creación del elemento tampoco, porque no hubo un estado anterior con el que comparar.

Esas cuatro cubren prácticamente todos los casos de “no me funciona la transición” y la última tiene su propia lección al final de este nivel, porque su mecanismo merece explicarse entero.

⚔️ Verifica el acortamiento de la reversión
  1. Monta una transición de 1000 milisegundos sobre translate y añade manejadores para los cuatro eventos.
  2. Interrúmpela al 25 por ciento y cronometra cuánto tarda la vuelta. Compáralo con 250 milisegundos.
  3. Interrúmpela al 25 por ciento pero mandando el elemento a un tercer valor, y cronometra otra vez. Explica la diferencia.
  4. Mueve la declaración transition a la regla del estado y comprueba qué dirección deja de animarse.