Del muelle a linear(): muestrear física y pegarla en CSS
Convertir la solución de un oscilador en una función linear() de CSS: elegir la duración, elegir el número de puntos, medir el error de la aproximación y saber qué se pierde por el camino.
La función linear() de CSS acepta una lista de valores y construye con ellos una curva de easing lineal a trozos. Suena a herramienta de nicho —¿quién quiere una curva hecha de segmentos rectos?— hasta que caes en que cualquier función puede aproximarse por segmentos si pones suficientes, incluida la solución exacta de un muelle con todos sus rebotes. linear() es el puente entre el mundo de la física simulada y el mundo declarativo de CSS: no da dinámica, pero da la forma exacta, sin JavaScript en el runtime y con la animación corriendo en el compositor.
- Convertir la deformación de un muelle en una curva de progreso entre cero y uno.
- Elegir la duración de la animación a partir de un umbral de asentamiento.
- Generar la cadena de
linear()con el número de puntos que la precisión pedida exija. - Enumerar con precisión qué propiedades del muelle sobreviven a la conversión y cuáles no.
De deformación a progreso
La solución del oscilador da la deformación u(t), que empieza en u₀ y tiende a cero. Un easing de CSS necesita lo contrario: una función que empieza en 0 y tiende a 1. La conversión es directa si normalizas por la deformación inicial:
progreso(t) = 1 - u(t)/u₀
Como todo el sistema es lineal, u(t)/u₀ no depende de u₀: el perfil normalizado es el mismo para un movimiento de diez píxeles y para uno de mil. Eso es exactamente lo que permite que la conversión funcione, porque un easing de CSS también es independiente de la distancia.
Con v₀ = 0 la expresión se simplifica y solo quedan ζ y ω₀:
u(t)/u₀ = e^(-ζ·ω₀·t) · [ cos(ω_d·t) + (ζ·ω₀/ω_d)·sin(ω_d·t) ]
El progreso resultante sobrepasa el 1 cuando el muelle está subamortiguado, y eso es correcto: linear() admite valores fuera del rango cero-uno igual que los admite cubic-bezier(), y son ellos los que producen el rebote visible.
Elegir la duración
Un muelle no tiene duración; una animación de CSS la necesita obligatoriamente. Hay que fabricarla, y el criterio honesto es el mismo del asentamiento: la duración es el último instante en que la deformación normalizada supera un umbral.
El umbral correcto no es el 2% del asentamiento clásico sino algo bastante más fino, porque aquí no estamos decidiendo cuándo parar un bucle sino dónde cortar una curva: si cortas al 2%, la curva termina con un salto visible del 2% del recorrido, que en un movimiento de 400 píxeles son ocho píxeles de tirón al acabar. Un umbral de 0,001 deja el corte por debajo de medio píxel en movimientos de hasta 500 píxeles y es un buen valor por defecto.
Con ζ = 0,5 y ω₀ = 14,14 rad/s el umbral de 0,001 da una duración de 0,898 segundos. Ese es un dato que se lee al revés y resulta muy útil: si el diseño pide 600 milisegundos y quieres ese carácter de rebote, tienes que subir ω₀ hasta que la duración calculada dé 600. La relación es inversa y proporcional, así que basta con multiplicar ω₀ por 898/600.
El generador
Cuarenta líneas y ya tienes muelles en CSS puro:
function muelleLinear({ zeta = 0.5, omega0 = 14.14, puntos = 24, epsilon = 0.001 }) {
if (zeta >= 1) throw new Error('Este generador cubre el caso subamortiguado');
const wd = omega0 * Math.sqrt(1 - zeta * zeta);
const B = (zeta * omega0) / wd;
const u = (t) => Math.exp(-zeta * omega0 * t) * (Math.cos(wd * t) + B * Math.sin(wd * t));
// Duracion: ultimo instante en que la deformacion supera el umbral.
let dur = 0;
for (let t = 10; t > 0; t -= 0.001) {
if (Math.abs(u(t)) > epsilon) { dur = t; break; }
}
const vals = [];
for (let i = 0; i <= puntos; i++) {
const t = (i / puntos) * dur;
vals.push(Number((1 - u(t)).toFixed(4)));
}
// Clavar los extremos: evita el error de redondeo en 0 y en 1.
vals[0] = 0;
vals[puntos] = 1;
return { css: `linear(${vals.join(', ')})`, duracion: dur };
}
const r = muelleLinear({ zeta: 0.5, omega0: 14.14, puntos: 24 });
console.log(r.duracion.toFixed(3)); // 0.898
console.log(r.css);
La salida real, lista para pegar:
.panel {
transition:
transform 898ms
linear(0, 0.1156, 0.3715, 0.6558, 0.8963, 1.0601, 1.1438, 1.1626,
1.1391, 1.0958, 1.0499, 1.0122, 0.9875, 0.9755, 0.9737,
0.9782, 0.9855, 0.9929, 0.9987, 1.0024, 1.0041, 1.0042,
1.0034, 1.0022, 1);
}
Se lee la física en los números: sube hasta 1,1626 —el sobrepaso del 16,3% que predice la fórmula para ζ = 0,5— baja hasta 0,9737, vuelve a subir a 1,0042 y se apaga. Tres rebotes decrecientes en una progresión geométrica, escritos en una hoja de estilos.
Como los puntos sin porcentaje explícito se reparten uniformemente, la lista completa define la curva sin más sintaxis. Y como linear() es un valor de <easing-function> normal, sirve igual en transition-timing-function, en animation-timing-function y en la opción easing de la Web Animations API.
El valor en t = 0 sale de la fórmula como 1 - 1 = 0 exacto, pero el redondeo a cuatro decimales del último punto da 0.999 o 1.0001 según el umbral. Un easing que no termina exactamente en 1 deja el elemento a fracción de píxel de su sitio y, peor, hace que el navegador no pueda simplificar la animación. Forzar vals[0] = 0 y vals[puntos] = 1 cuesta dos líneas y elimina toda una categoría de bugs de un píxel.
Cuántos puntos hacen falta
Más puntos es más precisión y más bytes. La forma de decidir es medir el error máximo entre la curva lineal a trozos y la función real, y para el muelle de los ejemplos sale así:
| Puntos | Error máximo | Longitud de la cadena |
|---|---|---|
| 8 | 0,1035 | unos 70 caracteres |
| 16 | 0,0490 | unos 130 |
| 24 | 0,0260 | 196 |
| 32 | 0,0158 | unos 260 |
| 48 | 0,0076 | unos 390 |
| 64 | 0,0044 | unos 520 |
| 96 | 0,0020 | unos 780 |
El error decrece aproximadamente con el cuadrado del número de puntos, como corresponde a una interpolación lineal a trozos, salvo en la zona de pocos puntos donde domina el arranque, que es la parte de mayor curvatura.
Traducir esa columna de error a algo decidible exige saber la distancia: un error de 0,0044 sobre un recorrido de 400 píxeles son 1,8 píxeles de desviación máxima respecto al muelle real. Para casi cualquier interfaz, entre 16 y 32 puntos es más que suficiente; por debajo de 16 se empieza a ver que la curva es poligonal en la subida inicial, y por encima de 64 estás pagando bytes por una precisión que nadie puede ver.
Hay una optimización que casi nadie usa y que reduce los puntos a la mitad: linear() acepta paradas explícitas en porcentaje, linear(0, 0.4 20%, 1). Eso permite muestrear denso donde la curvatura es alta —el arranque y el primer rebote— y ralo donde la curva es casi plana, que es toda la cola. Un muestreo adaptativo por curvatura con 14 puntos bien colocados iguala a uno uniforme de 32.
Qué sobrevive a la conversión y qué no
Esto es lo importante y donde mucha documentación es descuidada.
Sobrevive la forma: la trayectoria es la del muelle, con sus rebotes, su sobrepaso exacto y su decaimiento geométrico. Visualmente, una animación no interrumpida con esta linear() es indistinguible de una simulación.
Sobrevive el rendimiento, y mejora: la animación es declarativa, el navegador la conoce entera de antemano y puede ejecutarla en el compositor sin que tu JavaScript intervenga. Una simulación en requestAnimationFrame no puede hacer eso.
No sobrevive la dinámica. Y no es un matiz: es lo que hacía especial al muelle. Una linear() es una curva de easing como cualquier otra, con duración fija y sin estado, así que:
- La duración no depende de la distancia. Un movimiento de 10 píxeles y otro de 500 duran los mismos 898 ms, cosa que un muelle real jamás haría.
- Interrumpir a mitad produce la misma discontinuidad de velocidad que cualquier otra Bézier. La información de velocidad no está.
- Cambiar el objetivo a mitad de camino reinicia la curva desde cero.
Genera linear() cuando la animación se dispara y se completa: una entrada, un modal que aparece, un elemento que se coloca. Ahí lo único que quieres es la forma, y la ganas gratis con todas las ventajas de una animación declarativa.
Simula cuando la animación persigue algo que se mueve: un elemento que sigue al cursor, un panel arrastrable con inercia, un valor que rastrea el scroll. Ahí lo que necesitas es precisamente lo que linear() no puede darte, y usarla produce ese efecto de goma pegajosa tan característico de las interfaces que persiguen un objetivo móvil con curvas de duración fija: cada movimiento del objetivo reinicia una curva que empieza desde el reposo, y el resultado es un tartamudeo que ninguna cantidad de puntos arregla.
Y no confundas la herramienta con el modelo. linear() con 96 puntos no es “casi un muelle”: es una curva que se le parece mucho. La diferencia solo aparece en la interrupción, pero es exactamente donde el usuario está mirando, porque interrumpir una animación es un acto deliberado.
Amplía el generador para que acepte también los casos crítico y sobreamortiguado, usando las soluciones correspondientes, y añade un muestreo adaptativo: coloca los puntos donde la segunda derivada de la curva es mayor y emite las paradas en porcentaje. Compara el error máximo del muestreo adaptativo con el del uniforme para el mismo número de puntos y comprueba que ganas al menos un factor dos.