El easing declarado dentro de un keyframe
animation-timing-function dentro de un bloque de keyframe no afecta a toda la animación: gobierna solo el tramo que empieza ahí. El modelo por tramos y lo que implica.
Poner animation-timing-function dentro de un keyframe es de las cosas más malinterpretadas del CSS. La intuición dice que estás fijando la curva de la animación desde ese punto, o quizá la de toda la animación. Ninguna de las dos. Lo que haces es asignar una curva al tramo que arranca en ese keyframe, y solo a ese. La animación deja de tener una curva y pasa a tener una por segmento.
- Explicar el modelo de easing por tramo y no por animación.
- Predecir el efecto de un
animation-timing-functionen el keyframe final. - Componer una animación con curvas distintas en cada segmento.
- Distinguir cuándo un
steps()por tramo es la solución y cuándo no.
Una curva por segmento, no por animación
Cuando declaras animation-timing-function en la regla del elemento, estás dando el valor por defecto de la curva de todos los tramos. Cuando la declaras dentro de un bloque de keyframe, sobrescribes la curva de un tramo concreto: el que va desde ese keyframe hasta el siguiente.
@keyframes entrada {
0% { transform: translateY(40px); opacity: 0;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
70% { transform: translateY(0); opacity: 1;
animation-timing-function: ease-in-out; }
100% { transform: translateY(0) scale(1.02); }
}
.tarjeta { animation: entrada 700ms both; }
El tramo de 0% a 70% usa la curva de sobrepaso suave. El tramo de 70% a 100% usa ease-in-out. La regla del elemento no declara ninguna función de temporización, así que el valor por defecto —ease— no llega a aplicarse a ningún tramo porque los dos están cubiertos.
Esto convierte la regla @keyframes en algo más parecido a una curva compuesta que a una lista de valores. Un movimiento que acelera de golpe, mantiene velocidad constante y frena con inercia no se consigue con una sola cúbica de Bézier: se consigue con tres tramos, cada uno con su curva.
El keyframe final y su curva fantasma
Un animation-timing-function en el keyframe del 100% no hace nada. No hay tramo que empiece ahí, así que no hay nada que gobernar. La declaración es válida, se parsea sin quejas, y se ignora por completo.
@keyframes inutil {
0% { opacity: 0; }
100% { opacity: 1; animation-timing-function: ease-out; } /* no tiene efecto */
}
Este error es muy común porque la intuición dice “quiero que llegue suavemente al final, así que pongo la curva en el final”. La curva que gobierna la llegada al 100% es la que declara el keyframe anterior, porque es el que abre el tramo. Si quieres que la última parte de la animación desacelere, la declaración va en el keyframe desde el que arranca esa última parte.
Hay un matiz que salva a esa declaración de ser completamente inútil: cuando la animación se reproduce en sentido inverso —con animation-direction: reverse o alternate— el tramo sigue siendo el mismo tramo, y sigue gobernado por la función del keyframe con offset menor, ejecutada al revés. El 100% sigue sin abrir ningún tramo en ninguna dirección.
Componer velocidad con tramos
El caso donde esto se vuelve imprescindible es cualquier movimiento que no sea monótono en velocidad. Un péndulo, un rebote, un objeto que se detiene y arranca. Con una sola curva no puedes expresar “rápido, pausa, rápido”; con tramos sí.
@keyframes rebote {
0% { transform: translateY(-120px);
animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.2); }
40% { transform: translateY(0);
animation-timing-function: cubic-bezier(0.33, 0.8, 0.67, 1); }
62% { transform: translateY(-34px);
animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.2); }
80% { transform: translateY(0);
animation-timing-function: cubic-bezier(0.33, 0.8, 0.67, 1); }
91% { transform: translateY(-9px);
animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.2); }
100% { transform: translateY(0); }
}
Cada caída usa una curva que acelera y cada subida usa una que decelera, porque eso es lo que hace la gravedad. Los offsets no están repartidos de forma uniforme: cada rebote dura menos que el anterior, y esa proporción es la que hace que el movimiento parezca físico. Ninguna función de temporización única produce esto, por muy elaborada que sea la cúbica.
El mismo mecanismo con steps() da el otro caso clásico: una animación que avanza a saltos en una parte y de forma continua en otra. Un contador que sube discretamente y luego se desliza, un cursor que parpadea y después se desplaza.
La función linear() cambia el cálculo de cuándo merece la pena partir en tramos. linear() acepta una lista de puntos con paradas explícitas y aproxima cualquier curva arbitraria dentro de un solo tramo, incluyendo curvas de muelle amortiguado que ninguna cúbica de Bézier puede representar porque sobrepasan el destino varias veces. Antes de linear(), la única forma de expresar un muelle en CSS puro era generar veinte keyframes con un script. Ahora la regla práctica es: usa tramos con curvas distintas cuando los tramos tengan significado semántico —la caída, el rebote, el reposo—, y usa linear() cuando lo que quieras sea reproducir una curva calculada. Mezclar ambas cosas en la misma regla produce animaciones imposibles de ajustar, porque nunca sabes si un cambio de velocidad viene del offset o de la curva.
Lo que no puedes hacer por tramo
Dentro de un bloque de keyframe, las únicas propiedades de la familia de animación que tienen sentido son animation-timing-function y animation-composition. Todas las demás se ignoran: no puedes poner animation-duration en un keyframe para que un tramo dure más, ni animation-delay para introducir una pausa, ni animation-play-state para que se detenga a mitad.
La duración de un tramo se controla exclusivamente con la distancia entre offsets. Si quieres que la primera mitad visual de la animación ocupe el 80% del tiempo, el keyframe intermedio va en el 80%, no en el 50%. Es la única palanca, y es suficiente porque la suma de los tramos siempre es la duración total.
Una pausa se expresa igual: dos keyframes consecutivos con el mismo valor. De 40% a 70% con idéntico transform es una pausa de tres décimas partes de la duración. No hace falta ningún mecanismo especial porque interpolar entre dos valores iguales es quedarse quieto.
Coge la animación de rebote de arriba y elimina todos los animation-timing-function internos, dejando solo animation: rebote 1.2s ease en el elemento. Compara los dos movimientos lado a lado y describe con precisión qué se pierde: no es que quede “menos bonito”, es que la velocidad en el punto de impacto pasa a ser cero cuando debería ser máxima.