wandres.dev
CONTROLAR ANIMACIONES CSS · Las ocho propiedades

animation-fill-mode y el modelo de las tres fases

La propiedad que más se copia sin entender. El modelo de fase previa, activa y posterior, qué valor aporta la animación en cada una, y por qué forwards casi nunca es la respuesta correcta.

⏱ 20 min

animation-fill-mode: forwards es la declaración más copiada y menos entendida del CSS de animación. Se escribe por inercia, porque “si no, el elemento vuelve a su sitio”, y con eso se arrastra un efecto secundario que después produce bugs que nadie relaciona con ella. La propiedad no hace lo que su nombre sugiere. No prolonga la animación: decide si la animación aporta algún valor fuera de su ventana de actividad, y ese es un modelo completamente distinto.

🎯 Al terminar esta lección sabrás
  • Describir las tres fases de una animación y cuál de ellas existe siempre.
  • Definir forwards y backwards sin recurrir a los porcentajes de los keyframes.
  • Arreglar el destello de estilo base que produce un retardo positivo.
  • Sustituir forwards por el patrón de estado base cuando sea posible.

Tres fases y una pregunta

Una animación con retardo positivo atraviesa tres fases: la previa, desde que se aplica al elemento hasta que empieza a producir valores; la activa, que dura exactamente la duración activa; y la posterior, desde que termina hasta que la animación se retira del elemento.

flowchart LR
A[fase previa] --> B[fase activa] --> C[fase posterior]
A --> A1[none no aporta nada]
A --> A2[backwards aporta el primer valor]
C --> C1[none no aporta nada]
C --> C2[forwards aporta el ultimo valor]
style A fill:#89b4fa,color:#11111b
style B fill:#a6e3a1,color:#11111b
style C fill:#f9e2af,color:#11111b
style A2 fill:#cba6f7,color:#11111b
style C2 fill:#cba6f7,color:#11111b

En la fase activa la animación siempre manda: sus valores ocupan el escalón de animaciones en la cascada, por encima de cualquier declaración normal de autor. La pregunta que responde animation-fill-mode es qué pasa en las otras dos. Y la respuesta por defecto, con none, es que la animación desaparece de la cascada: el elemento muestra el valor que le tocaría por cascada normal, como si no hubiera ninguna animación.

La fase previa solo existe si el retardo es positivo. Con retardo cero o negativo no hay nada que rellenar por delante, y backwards no tiene ningún efecto observable. La fase posterior existe siempre que la duración activa sea finita; con infinite no hay fase posterior y forwards es igual de irrelevante.

Las dos definiciones que hay que memorizar

Olvida los porcentajes. Las definiciones correctas no hablan de keyframes:

  • backwards: durante la fase previa, la animación aporta el valor que produciría en el primer instante de su fase activa.
  • forwards: durante la fase posterior, la animación aporta el valor que produjo en el último instante de su fase activa.

both es las dos cosas. none es ninguna.

Estas definiciones son mejores que cualquier tabla porque resuelven solas todos los casos raros. Con animation-direction: reverse, el primer instante corresponde al keyframe del 100%, así que eso es lo que aplica backwards. Con alternate y dos iteraciones, el último instante está al final de una iteración que iba marcha atrás, así que forwards congela el valor del 0%. Con iteration-count: 2.5, el último instante cae a mitad de recorrido y forwards congela un valor interpolado que no corresponde a ningún keyframe escrito. No hay que recordar nada: se aplica la definición.

@keyframes crecer { from { transform: scale(1); } to { transform: scale(2); } }

/* Termina en scale(1): la ultima iteracion iba hacia atras */
.par   { animation: crecer 400ms 2 alternate forwards; }

/* Termina en scale(2) */
.impar { animation: crecer 400ms 3 alternate forwards; }

/* Termina en scale(1.5): el ultimo instante cae a mitad de camino */
.medio { animation: crecer 400ms 1.5 forwards; }

El destello que arregla backwards

El caso donde backwards es imprescindible es la entrada escalonada. Quieres que una lista aparezca elemento a elemento, así que declaras un retardo creciente y un opacity: 0 en el keyframe del 0%.

@keyframes aparecer {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: none; }
}

.item {
  animation: aparecer 400ms ease-out;
  animation-delay: calc(var(--i) * 60ms);
}

El resultado es un desastre: todos los elementos son visibles desde el primer frame, y luego van desapareciendo y reapareciendo uno a uno. La razón es exacta: durante la fase previa, con fill-mode: none, la animación no aporta nada, así que el elemento muestra su opacidad de cascada, que es 1. Cuando llega su turno, la animación entra en fase activa y su primer valor es opacity: 0, de golpe.

Añadir backwards lo resuelve por completo, porque ahora durante toda la espera la animación aporta el valor de su primer instante:

.item {
  animation: aparecer 400ms ease-out backwards;
  animation-delay: calc(var(--i) * 60ms);
}

Esta es la razón de ser de backwards, y es un caso donde no hay alternativa: cualquier otro arreglo pasa por poner opacity: 0 en el estilo base, lo cual deja los elementos invisibles para siempre si el CSS carga y la animación no arranca.

Por qué forwards suele ser la respuesta equivocada

forwards funciona, pero tiene un coste que no es evidente: la animación nunca se retira. Sigue en fase de relleno indefinidamente, sigue aportando su valor, y sigue ocupando el escalón de animaciones de la cascada por encima de cualquier declaración normal.

La consecuencia práctica es que a partir de ese momento el elemento deja de responder a los estilos. Añades una clase que cambia el transform, y no pasa nada. Escribes elemento.style.opacity desde JavaScript, y no pasa nada. El inspector te enseña tu declaración aplicada y tachada, con la animación ganando. Es exactamente la situación que analizamos en la cascada de los keyframes, y forwards es su causa más habitual.

El patrón alternativo, que resuelve el mismo problema sin ese coste, es hacer que el estado final de la animación sea el estado base del elemento:

/* En vez de forzar el destino con forwards... */
.modal { opacity: 0; animation: aparecer 300ms forwards; }

/* ...haz que el destino sea el estilo normal y anima desde el origen */
.modal { opacity: 1; animation: aparecer 300ms backwards; }
@keyframes aparecer {
  from { opacity: 0; transform: scale(0.96); }
  to   { opacity: 1; transform: none; }
}

En la segunda versión, cuando la animación termina y se retira, el elemento se queda exactamente donde estaba porque su estilo de cascada ya es el destino. Nada se congela, nada bloquea la cascada, y si después quieres cambiar la opacidad desde una clase o desde JavaScript, funciona. La animación pasa a ser lo que debería ser siempre: una descripción de cómo se llega, no de dónde se acaba.

Nivel dios

Hay un caso donde el patrón del estado base no vale y forwards es inevitable: cuando el valor final no se puede expresar como estilo estático porque depende de la propia animación. El ejemplo típico es un iteration-count fraccionario, donde el último instante cae en un valor interpolado que no corresponde a ningún keyframe, o una animación cuyo destino es un keyframe implícito. Ahí no hay ningún valor que escribir en el estilo base porque el valor lo calcula el motor. Para esos casos, la salida limpia no es vivir con el relleno indefinido, sino volcarlo y quitarlo: desde WAAPI, animacion.commitStyles() escribe el valor computado del efecto en el estilo en línea del elemento, y animacion.cancel() retira la animación de la cascada. El elemento se queda donde estaba, con un valor real que puedes sobrescribir después. Es la única forma de terminar una animación de verdad. Y es también el motivo por el que commitStyles() lanza una excepción si el elemento no está siendo renderizado: no hay valor computado que volcar.

Combinaciones que hay que reconocer

Con retardo negativo, backwards no hace nada porque no hay fase previa. La combinación animation: x 1s -0.5s backwards es ruido: alguien copió backwards de otro sitio.

Con infinite, forwards no hace nada porque no hay fase posterior. animation: x 1s infinite forwards es la misma clase de ruido, y aparece muchísimo en código copiado.

Con both y retardo cero, both equivale a forwards, porque la mitad backwards no tiene fase donde aplicarse. Escribir both cuando no hay retardo no está mal, pero delata que la elección no fue deliberada.

Y una que sí importa: una animación pausada con animation-play-state: paused en su fase previa muestra el relleno backwards si lo tiene, y su estilo base si no. Pausar no congela lo que se ve, congela el tiempo local; lo que se ve lo sigue decidiendo la fase y el modo de relleno.

⚔️ Reto práctico

Coge una lista de seis elementos con entrada escalonada y fill-mode: none. Grábala con el panel de rendimiento y cuenta cuántos frames son visibles antes de desaparecer. Después cámbialo a backwards y comprueba que el destello desaparece. Por último, convierte la animación al patrón de estado base y verifica que puedes cambiar la opacidad desde la consola cuando termina, cosa que con forwards no podías.