wandres.dev
TRANSITIONS · La animación de estado

La transición que no dispara: el mecanismo completo

Por qué un elemento recién insertado salta en vez de transicionar, qué hacen exactamente el doble requestAnimationFrame y getComputedStyle, y cuál usar.

⏱ 20 min

Insertas un elemento con opacity: 0, le quitas la clase en la línea siguiente, y en lugar de un desvanecido ves el elemento aparecer de golpe. Buscas en internet, encuentras que hay que llamar a requestAnimationFrame dos veces o leer offsetWidth, lo pruebas y funciona. Ese es el punto donde casi todo el mundo se detiene, y es un mal sitio para detenerse: los dos remedios funcionan por razones distintas, uno de los dos falla en casos que el otro cubre, y ninguno es la solución correcta en 2026 para el caso más común. Esta lección explica el mecanismo entero.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el motor no ve un cambio donde tú viste dos estados.
  • Describir qué hace exactamente una lectura de estilo computado y en qué se diferencia del doble rAF.
  • Decidir cuál de los tres remedios corresponde a cada situación.
  • Verificar con el modelo de animación si una transición llegó a crearse.

El motor no computa estilo cuando tú cambias el DOM

Un motor de renderizado no recalcula estilo cada vez que tocas una clase. Sería absurdamente caro: un bucle que añade cien nodos provocaría cien recálculos completos. Lo que hace es marcar el árbol como sucio y aplazar, y computar cuando lo necesita: antes de actualizar el renderizado, o cuando alguien pide un dato que exige tener el estilo al día.

Cada una de esas ocasiones es un evento de cambio de estilo. Y una transición arranca cuando, en un evento de cambio de estilo, el valor computado de una propiedad transicionable difiere del que tenía en el evento anterior. La especificación llama a esos dos valores el estilo anterior al cambio y el estilo posterior al cambio.

Ahora mira el código que falla con esa definición en la mano:

// Falla: el elemento aparece de golpe.
const aviso = document.createElement('div');
aviso.className = 'aviso';        // opacity: 0; transition: opacity 300ms;
document.body.append(aviso);
aviso.classList.add('visible');   // opacity: 1;

Las cuatro líneas se ejecutan en la misma tarea de JavaScript, sin ceder el control en ningún momento. El motor no computa estilo entre la tercera y la cuarta: no tiene ninguna razón para hacerlo. Cuando por fin computa, al final de la tarea y antes de actualizar el renderizado, el elemento ya tiene las dos clases y su opacidad computada es 1.

¿Y el estilo anterior al cambio? No existe. Es la primera vez que ese elemento se computa. Sin dos valores que comparar no hay cambio, y sin cambio no hay transición. No es que la transición sea demasiado rápida ni que se pierda: es que nunca se crea.

Se puede verificar directamente, y merece la pena hacerlo una vez para no volver a dudar:

// Cuenta las transiciones que existen sobre el elemento.
console.log(aviso.getAnimations().length);   // 0 en el caso que falla

Si ese número es cero, el problema es este y no otro. Si es uno y aun así no ves nada, el problema es de duración, de cascada o de propiedad no interpolable.

Remedio uno: forzar un evento de cambio de estilo

Si el problema es que falta un evento de cambio de estilo entre los dos estados, la solución directa es provocarlo. Cualquier lectura que exija estilo actualizado sirve, y la más barata es leer una propiedad del estilo computado.

const aviso = document.createElement('div');
aviso.className = 'aviso';
document.body.append(aviso);

// Fuerza aqui el calculo de estilo: el elemento adquiere
// su estilo anterior al cambio, con opacity 0.
getComputedStyle(aviso).opacity;

aviso.classList.add('visible');   // ahora si hay dos valores que comparar

Hay un detalle que importa y que hace fallar la versión de este truco que circula por ahí: hay que leer una propiedad, no llamar solo a la función. getComputedStyle(el) devuelve un objeto vivo que no obliga a nada; el recálculo se dispara al acceder a una propiedad de ese objeto. Escribir getComputedStyle(aviso); a secas puede no hacer nada en absoluto.

La variante con offsetWidth también funciona, y funciona por una razón más fuerte: la geometría exige layout, y layout exige estilo. Es decir, fuerza dos etapas en lugar de una.

void aviso.offsetWidth;   // fuerza estilo y layout

Entre las dos, la lectura de estilo computado es preferible por coste: hace lo mínimo necesario. El offsetWidth se sigue viendo mucho por herencia histórica, cuando no todos los motores hacían el recálculo al leer estilo computado.

El coste de cualquiera de las dos no es despreciable y conviene saberlo: un recálculo síncrono de estilo afecta a todo lo que estuviera sucio en ese momento, no solo a tu elemento. Si lo haces dentro de un bucle que inserta cincuenta nodos, tienes cincuenta recálculos completos. La corrección es insertarlos todos, forzar una vez, y añadir las clases a todos.

// Correcto para varios elementos: un solo recalculo forzado.
const nodos = datos.map(crearNodo);
lista.append(...nodos);

getComputedStyle(lista).opacity;   // una sola vez

for (const n of nodos) n.classList.add('visible');

Remedio dos: el doble requestAnimationFrame

El otro remedio no fuerza nada: espera a que el evento de cambio de estilo ocurra por sí solo. Y para entender por qué hacen falta dos llamadas y no una, hay que recordar dónde se ejecutan los callbacks de rAF dentro del ciclo del fotograma: antes del cálculo de estilo y layout de ese fotograma, tal y como vimos en la anatomía de un fotograma.

Sigue la secuencia con una sola llamada:

  1. Tu tarea inserta el elemento con la clase inicial y pide un rAF.
  2. La tarea termina. Todavía no ha habido cálculo de estilo.
  3. Llega el fotograma. Se ejecutan los callbacks de rAF, y el tuyo añade la clase final.
  4. Ahora el navegador computa estilo para ese fotograma. Ve el elemento por primera vez, ya con las dos clases.

Un solo rAF no arregla nada, porque el cambio sigue ocurriendo antes del único cálculo de estilo. Con dos:

  1. Tu tarea inserta el elemento y pide el primer rAF.
  2. Llega el fotograma N. El primer callback se ejecuta y solo pide el segundo rAF.
  3. El navegador computa estilo para el fotograma N. Aquí el elemento adquiere su estilo anterior al cambio, con la clase inicial.
  4. Llega el fotograma N más uno. El segundo callback añade la clase final.
  5. El navegador computa estilo para ese fotograma. Hay dos valores distintos: la transición arranca.

El paso 3 es el que hacía falta y es lo único que el primer rAF consigue. No es un truco supersticioso: es esperar a que el ciclo produzca el evento de cambio de estilo que necesitas.

// Espera un evento de cambio de estilo sin forzarlo.
const dosFrames = () =>
  new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));

async function mostrar(aviso) {
  document.body.append(aviso);
  await dosFrames();
  aviso.classList.add('visible');
}

La ventaja frente a forzar es que no provoca trabajo síncrono: el recálculo ocurre en su turno natural. La desventaja es que cuesta al menos un fotograma de retraso, y en el peor caso dos, lo que en una interfaz que responde a una pulsación es latencia perceptible. Además, si la pestaña está en segundo plano los callbacks de rAF no se ejecutan, así que tu elemento se queda esperando indefinidamente.

Los dos remedios no son intercambiables: uno arregla el caso que el otro no puede tocar

Se presentan como dos formas de hacer lo mismo y hay un caso donde solo funciona uno, que además es el caso más frecuente en interfaces reales: el elemento que pasa de display: none a visible. Aquí no basta con provocar un evento de cambio de estilo, porque el problema es de otra naturaleza. Un elemento con display: none no se está renderizando, y las transiciones no se ejecutan sobre elementos que no se renderizan. Puedes forzar todos los recálculos que quieras: el estilo anterior al cambio de un elemento que no existía visualmente no sirve como punto de partida. Y el doble rAF tampoco lo arregla, porque el problema no es de temporización sino de estado. La secuencia que sí funciona es distinta y tiene tres pasos: primero pones display a su valor visible sin el estado final, después fuerzas el evento de cambio de estilo, y solo entonces aplicas el estado final. Es decir, tienes que hacer que el elemento exista visualmente en su estado inicial durante al menos un cálculo de estilo. La versión declarativa de esa misma secuencia es exactamente lo que resuelve @starting-style junto con transition-behavior: allow-discrete, y es la razón de que esas dos capacidades se diseñaran juntas: una declara el estado que el elemento tiene antes de existir, y la otra permite que display participe en la transición para que el elemento siga renderizándose mientras dura. Con las dos, todo el baile de rAF y de lecturas forzadas desaparece, y desaparece también la latencia de fotograma que introducía. En 2026 esa es la respuesta correcta para entradas y salidas, y las técnicas de esta lección quedan para lo que no cubren: elementos que ya están renderizándose y cuyo estado cambia dentro de la misma tarea.

La secuencia manual para el caso de display, cuando no puedas usar las capacidades declarativas:

function abrirPanel(panel) {
  panel.style.display = 'block';       // ya se renderiza, en estado inicial
  getComputedStyle(panel).opacity;     // evento de cambio de estilo
  panel.classList.add('visible');      // ahora si transiciona
}

function cerrarPanel(panel) {
  panel.classList.remove('visible');
  panel.addEventListener('transitionend', function fin(e) {
    if (e.target !== panel || e.propertyName !== 'opacity') return;
    panel.removeEventListener('transitionend', fin);
    panel.style.display = 'none';
  });
}

Fíjate en la asimetría: la apertura necesita el remedio y el cierre necesita esperar al final para poder ocultar. Esa asimetría es exactamente la que las capacidades del nivel 6 eliminan.

Cuál usar

Situación Remedio correcto
Elemento nuevo que aparece @starting-style, y si no, forzar el estilo
Elemento que sale de display: none @starting-style con allow-discrete
Estado que cambia dos veces en la misma tarea Forzar el estilo entre los dos
Muchos elementos a la vez Forzar una vez tras insertarlos todos
Código que no puede provocar trabajo síncrono Doble rAF
Pestaña que puede estar en segundo plano Forzar el estilo, nunca rAF

Y una regla de diagnóstico que ahorra la mayor parte del tiempo: antes de aplicar ningún remedio, comprueba getAnimations().length. Si es cero, es este problema. Si no lo es, estás a punto de arreglar algo que no está roto.

⚔️ Reproduce y arregla los tres casos
  1. Reproduce el fallo con un elemento recién insertado y confirma con getAnimations() que la transición no existe.
  2. Arréglalo con la lectura forzada y confirma que ahora existe.
  3. Prueba a arreglarlo con un solo requestAnimationFrame y explica por qué no basta.
  4. Reproduce el caso de display: none y comprueba que la lectura forzada por sí sola no lo arregla. Implementa la secuencia de tres pasos.