wandres.dev
EASING · cubic-bezier, steps y linear()

steps() y los cuatro tipos de salto

La semántica exacta de jump-start, jump-end, jump-none y jump-both, y cómo se aplica a hojas de sprites y a efectos de máquina de escribir.

⏱ 17 min

steps() es la otra familia de funciones de suavizado: en lugar de un progreso continuo, produce una escalera. Su documentación se suele resumir en “divide la animación en N pasos”, lo cual es cierto y no basta para elegir entre los cuatro tipos de salto, que difieren en algo muy concreto: cuáles de los valores extremos se llegan a mostrar. Elegir mal produce el error clásico del sprite que muestra un fotograma en blanco o del texto que empieza con una letra ya escrita.

🎯 Al terminar esta lección sabrás
  • Calcular la salida de steps() para los cuatro tipos de salto.
  • Elegir el tipo correcto según qué extremos deben mostrarse.
  • Montar una animación de hoja de sprites que no muestre fotogramas fantasma.
  • Construir un efecto de máquina de escribir con el número de pasos correcto.

Los cuatro saltos, calculados

La función es steps(<entero>, <tipo>), donde el tipo por defecto es jump-end. Para calcular la salida, el motor hace esto: toma el progreso de entrada, lo multiplica por el entero, aplica el suelo, suma uno si el tipo empieza con salto, y divide por un número de saltos que depende del tipo.

Tipo Se suma al paso Divisor Efecto
jump-start 1 n Salta al principio, nunca muestra 0
jump-end 0 n Salta al final, nunca muestra 1 en el interior
jump-none 0 n - 1 Muestra los dos extremos
jump-both 1 n + 1 No muestra ninguno de los dos

Con n igual a 4, los valores de progreso que produce cada uno durante el intervalo abierto de 0 a 1:

Tipo Valores producidos
steps(4, jump-start) 0.25, 0.5, 0.75, 1
steps(4, jump-end) 0, 0.25, 0.5, 0.75
steps(4, jump-none) 0, 0.333, 0.667, 1
steps(4, jump-both) 0.2, 0.4, 0.6, 0.8

Léelo así: jump-start se come el valor inicial, jump-end se come el final, jump-none conserva los dos y jump-both se come los dos. Y hay una consecuencia aritmética que sorprende: con jump-none, n pasos producen n valores distintos repartidos entre 0 y 1 inclusive, mientras que con los otros tres, n es el número de intervalos, no de valores mostrados.

Dos palabras clave adicionales existen como atajos de un solo paso: step-start equivale a steps(1, jump-start) y step-end a steps(1, jump-end). La primera cambia el valor inmediatamente al empezar; la segunda, al terminar. Son útiles para propiedades discretas.

Y una restricción sintáctica: jump-none con n igual a 1 es inválido, porque el divisor sería cero. El motor descarta la declaración entera.

Hojas de sprites

Una hoja de sprites es una imagen con los fotogramas de una animación en fila. La técnica consiste en mostrar una ventana del tamaño de un fotograma y desplazar la posición del fondo a saltos.

Con diez fotogramas de 120 píxeles cada uno, la hoja mide 1200 de ancho. El desplazamiento va de 0 a menos 1200, y quieres visitar los diez fotogramas, es decir los desplazamientos de 0 a menos 1080.

.corredor {
  width: 120px;
  height: 120px;
  background-image: url('/sprites/correr.png');
  background-repeat: no-repeat;
  background-size: 1200px 120px;    /* 10 fotogramas de 120px */
  animation: correr 800ms steps(10) infinite;
}

@keyframes correr {
  from { background-position-x: 0; }
  to   { background-position-x: -1200px; }
}

steps(10) es jump-end, que produce los progresos 0, 0.1, …, 0.9, es decir los desplazamientos 0, menos 120, …, menos 1080. Exactamente los diez fotogramas.

El fotograma fantasma aparece si usas jump-start o si animas hasta menos 1080 en lugar de menos 1200. En el primer caso te saltas el primer fotograma y muestras uno vacío al final; en el segundo, cada fotograma dura una décima parte de más y el último se ve el doble.

Cuando la animación no es infinita, hay un matiz: en el instante exacto en que el progreso vale 1, jump-end produce 1, es decir el desplazamiento de menos 1200, que está fuera de la hoja. Con infinite no se percibe porque la iteración reinicia inmediatamente. Con una sola iteración y forwards, el elemento se queda mostrando el vacío. La corrección es terminar la animación un fotograma antes o usar jump-none con once pasos, que reparte los mismos diez fotogramas incluyendo el último dentro del intervalo.

Máquina de escribir

El efecto consiste en revelar el texto carácter a carácter aumentando el ancho de un contenedor con overflow: hidden. Requiere fuente monoespaciada, porque la unidad ch mide el ancho del glifo cero y solo coincide con el ancho de todos los caracteres si la fuente lo es.

.escribe {
  font-family: ui-monospace, monospace;
  width: 0;
  overflow: hidden;
  white-space: nowrap;
  border-right: 2px solid currentColor;
  animation:
    tecleo 2.4s steps(32, jump-none) forwards,
    cursor 700ms step-end infinite;
}

@keyframes tecleo { to { width: 32ch; } }
@keyframes cursor { 50% { border-right-color: transparent; } }

El número de pasos es el número de caracteres. Y el tipo correcto es jump-none, no el jump-end que aparece en la mayoría de los ejemplos que circulan, por una razón concreta: con 32 caracteres y jump-end, los anchos producidos son 0, 1ch, …, 31ch, y el ancho de 32ch solo aparece en el instante final. Es decir, el último carácter se muestra durante cero tiempo salvo por el relleno hacia delante. Con jump-none, los 32 anchos se reparten uniformemente entre 0 y 32ch, y el último carácter tiene la misma duración que los demás.

El cursor usa step-end con una animación de 700 milisegundos que cambia el color al 50 por ciento: eso produce un parpadeo binario en lugar de un desvanecido, que es lo que hace un terminal de verdad.

steps() no discretiza el tiempo: discretiza el progreso, y por eso no ahorra ni un fotograma

Hay una creencia muy extendida sobre steps() que lleva a usarla como técnica de optimización: que una animación con steps(10) hace una décima parte del trabajo, porque “solo cambia diez veces”. Es falsa, y entender por qué aclara de paso qué es una función de suavizado. steps() no interviene en el reloj: el motor sigue evaluando la animación en cada fotograma, sesenta o ciento veinte veces por segundo, exactamente igual que con cualquier otra curva. Lo único que hace la función es que, para muchos de esos fotogramas, el progreso transformado salga igual que en el anterior. El trabajo de evaluación se hace igualmente. Lo que sí cambia, y esto sí es real, es el trabajo posterior: si el valor resultante es idéntico al del fotograma anterior, no hay nada que invalidar, así que no hay repintado ni recomposición para ese fotograma. En una animación de background-position sobre un sprite, eso significa que de sesenta fotogramas por segundo solo diez producen repintado. La distinción importa por dos motivos prácticos. El primero es que la evaluación de la animación sigue costando, así que mil elementos con steps() cuestan lo mismo en evaluación que mil elementos con ease. El segundo, más útil: el ahorro solo existe si la propiedad animada produce el mismo valor computado en fotogramas consecutivos. Si combinas steps() con una propiedad cuyo valor sigue variando —porque otra animación la toca, o porque depende de una variable que cambia— el ahorro desaparece y solo te queda el aspecto escalonado. steps() es una decisión estética con un efecto secundario agradable, no una técnica de rendimiento.

Otros usos de la escalera

Además de sprites y texto, steps() resuelve bien tres cosas.

Contadores discretos. Un número que sube de 0 a 100 debe hacerlo por enteros, no mostrando decimales. Con una custom property registrada como <integer> y steps(100), el valor recorre exactamente los cien enteros.

@property --n {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

.contador {
  counter-reset: n var(--n);
  animation: contar 2s steps(100, jump-none) forwards;
}
.contador::after { content: counter(n); }

@keyframes contar { to { --n: 100; } }

Indicadores segmentados. Una barra de progreso que avanza por bloques en lugar de continuamente. Comunica que hay un número finito de pasos, que a menudo es información útil.

Movimiento mecánico deliberado. Una aguja de reloj que salta de segundo en segundo es más creíble que una que se desliza. steps(60) sobre una rotación completa reproduce exactamente el comportamiento de un reloj de cuarzo.

.segundero {
  transform-origin: 50% 90%;
  animation: girar 60s steps(60, jump-start) infinite;
}
@keyframes girar { to { rotate: 360deg; } }

Fíjate en el jump-start: un reloj muestra el primer salto de inmediato, no espera un segundo entero en la posición cero.

⚔️ Calcula antes de probar
  1. Para steps(5) en sus cuatro variantes, escribe la lista de progresos que produce cada una. Compruébalo animando una custom property registrada como <number> y leyéndola.
  2. Monta un sprite de seis fotogramas y provoca a propósito el fotograma fantasma. Corrígelo de las dos formas descritas.
  3. Escribe un efecto de máquina de escribir con jump-end y otro con jump-none y compara la duración del último carácter.
  4. Comprueba con el destello de repintado que una animación con steps() no repinta en todos los fotogramas.