animation-range en la práctica
La sintaxis completa de animation-range, mezclar nombres con porcentajes y longitudes, y qué ocurre cuando el inicio va después del final.
Con el modelo de rangos entendido, escribir animation-range es casi mecánico. Casi: la propiedad admite cuatro formas de valor que se combinan libremente, tiene un atajo con una regla de reparto que no es la que esperas, y produce un resultado perfectamente definido pero inútil cuando el inicio cae después del final. Esta lección cubre la sintaxis y los tres casos límite que aparecen en producción.
- Escribir
animation-rangeen sus cuatro formas de valor y saber cuándo hace falta cada una. - Predecir cómo reparte el atajo un valor único entre inicio y final.
- Combinar rangos de una view timeline con longitudes absolutas.
- Diagnosticar una animación que se completa de golpe o que no se completa nunca.
Las cuatro formas de un extremo
animation-range es el atajo de animation-range-start y animation-range-end. Cada uno de esos dos extremos acepta cuatro tipos de valor:
normal, el valor inicial. Sobre una view timeline significa el principio o el final de cover; sobre una scroll timeline, el principio o el final del recorrido scrollable.
Un porcentaje o longitud a secas, como 20% o 240px. Se mide sobre la timeline completa, es decir, sobre cover en el caso de una view timeline. animation-range: 0 240px significa “los primeros doscientos cuarenta píxeles de esta timeline”.
Un nombre de rango, como entry o contain. Equivale a ese nombre con el porcentaje implícito que corresponda al extremo: entry como inicio es entry 0%, y como final es entry 100%.
Un nombre de rango seguido de porcentaje o longitud, como entry 50% o cover 100px. Es la forma completa y la que conviene usar siempre que no sea evidente, porque se lee sin ambigüedad.
.tarjeta {
animation: aparecer linear both;
animation-timeline: view();
/* Las cuatro escrituras siguientes son equivalentes dos a dos */
animation-range: entry; /* = entry 0% entry 100% */
animation-range: entry 0% entry 100%;
animation-range-start: cover 15%;
animation-range-end: cover 60%;
/* = animation-range: cover 15% cover 60%; */
}
La regla del atajo con un solo valor tiene una bifurcación que hay que conocer, porque no se comporta como margin ni como padding, que duplican. Aquí depende del tipo de valor:
- Si el valor único es un nombre de rango sin porcentaje, el final se completa con ese mismo nombre y
100%.animation-range: entryesentry 0% entry 100%. - Si el valor único es cualquier otra cosa —un porcentaje, una longitud, un nombre con porcentaje—, el final se queda en
normal.animation-range: 30%es30%de inicio y el final de la timeline como final.
La lógica detrás de la bifurcación es que un nombre describe un intervalo y un porcentaje describe un punto, así que expandir un nombre a su intervalo completo es lo único razonable, mientras que expandir un punto no significaría nada. Es un detalle que no se recuerda y no hace falta recordarlo: escribe siempre los dos extremos y la ambigüedad desaparece.
Mezclar unidades y rangos
Los dos extremos no tienen por qué ser del mismo tipo, y de esa libertad salen los efectos más útiles.
/* Empieza cuando la tarjeta ha entrado un 20 por ciento
y acaba 200 pixeles despues de haber entrado del todo */
.tarjeta {
animation-range: entry 20% entry calc(100% + 200px);
}
Sí, calc() funciona, y la mezcla de porcentaje y longitud dentro del mismo calc() también, porque el porcentaje resuelve contra la longitud del rango. Es la vía para expresar “un poco más allá del final” sin inventarse un porcentaje que dependa del tamaño de la pantalla.
Las longitudes absolutas dentro de un rango nombrado se miden desde el inicio de ese rango, no desde el inicio de la timeline. entry 100px es “cien píxeles después de que el elemento empiece a asomar”. Esa es la forma correcta de decir “que reaccione en los primeros cien píxeles de su entrada”, y es estable frente al tamaño del elemento, a diferencia de un porcentaje.
Un caso concreto que aparece constantemente: una imagen con paralaje que tiene que moverse durante todo su paso pero solo un poco, para no dejar huecos.
@keyframes paralaje {
from { transform: translateY(-12%); }
to { transform: translateY(12%); }
}
.figura {
overflow: hidden;
}
.figura > img {
block-size: 124%;
object-fit: cover;
animation: paralaje linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
El 124% de altura y el 12% de desplazamiento están relacionados: la imagen tiene que ser más alta que su marco en, al menos, el doble del desplazamiento, o se verá el hueco en los extremos. Ese cálculo es la parte que nunca aparece en los tutoriales y la que hace que el efecto se vea roto en el primer y último elemento de la página.
Cuando el inicio va después del final
Es perfectamente legal escribir animation-range: exit 100% entry 0%, con el inicio geométricamente posterior al final. El motor no se queja. El resultado está definido y es inútil: el rango activo tiene longitud cero o negativa, y la animación no progresa; con fill-mode: both te quedas viendo uno de los dos extremos fijo.
Esto pasa más de lo que parece, y no por escribirlo a mano sino por dos vías indirectas. La primera es la de siempre: un sujeto más grande que el scrollport hace que contain tenga duración cero, y cualquier rango construido sobre contain se degenera con él. La segunda es más traicionera: view-timeline-inset con valores grandes puede recortar el rango de visibilidad hasta el punto de que las cuatro posiciones clave se crucen.
El diagnóstico ordenado cuando “la animación no progresa”:
| Observación | Causa | Qué mirar |
|---|---|---|
| Se ve el primer keyframe siempre | Rango de longitud cero o negativa | Compara la altura del sujeto con la del viewport |
| Se ve el último keyframe siempre | Rango que queda íntegro por detrás del scroll actual | ¿Has llegado a la página con un ancla? |
| Salta de golpe en un píxel | contain degenerado en móvil |
Cambia a entry o entry-crossing |
| Va bien en escritorio, mal en móvil | El rango depende del tamaño relativo | Usa rangos de cruce, no de contención |
| No pasa absolutamente nada | Timeline inactiva, no rango | El sujeto no scrollea en ese eje |
Los rangos nombrados no son constantes: dependen de la altura del sujeto y del tamaño del scrollport, así que cualquier cosa que cambie el layout cambia dónde caen las cuatro posiciones clave. La especificación se toma esto muy en serio: recoge en cada frame las timelines cuyos rangos han cambiado, las marca como obsoletas, y ejecuta una ronda adicional de recálculo de estilo y layout para que no se vea un fogonazo del estado sin animar. Es una segunda pasada completa, dentro del mismo frame, y ocurre después de despachar los ResizeObserver precisamente para tener en cuenta los elementos redimensionados por código. Las consecuencias son tres y ninguna es obvia. Primera: una animación de scroll que altera el layout se paga dos veces, porque provoca la ronda extra en cada frame en que el rango cambie; es otro argumento para animar solo transform, opacity y filter. Segunda: la ronda extra está acotada a una sola por frame por diseño, así que no puedes provocar un bucle infinito, pero sí puedes provocar que el estado que ves vaya un frame por detrás. Tercera, y la más útil en la práctica: si cargas fuentes web sin size-adjust y el texto se recompone al llegar la fuente, todos los rangos de la página se recalculan a la vez, y lo que el usuario percibe es que las animaciones de scroll “se recolocan solas” un segundo después de cargar. La causa no está en tu CSS de animación: está en la carga de fuentes.
Un patrón que conviene tener a mano
Encadenar dos animaciones sobre la misma timeline con rangos contiguos es el equivalente en scroll de una secuencia. No hace falta ninguna herramienta especial: dos nombres de animación, dos rangos, dos timelines iguales.
@keyframes entrar { from { opacity: 0; transform: translateY(3rem); } }
@keyframes salir { to { opacity: 0; transform: translateY(-3rem); } }
.bloque {
animation-name: entrar, salir;
animation-timing-function: linear;
animation-fill-mode: both, both;
animation-timeline: view(), view();
animation-range: entry 0% entry 100%, exit 0% exit 100%;
}
Las cuatro propiedades aceptan listas y se emparejan por posición con animation-name. Si hay menos valores que nombres, la lista se repite en orden hasta cubrirlos todos: por eso animation-timing-function: linear sin repetir también funcionaría. La repetición explícita se lee mejor cuando hay más de dos.
El resultado es un bloque que entra al aparecer y se va al desaparecer, con la zona central quieta, y sin una sola línea de JavaScript ni un solo observador.