Un muelle amortiguado en CSS puro
La ecuación del oscilador armónico amortiguado, cómo se generan sus puntos para linear(), y qué se gana y qué se pierde frente a un muelle de verdad.
Un muelle es la única forma de movimiento que la gente reconoce como natural sin pensarlo, porque es la que gobierna casi todo lo que se mueve y se para en el mundo físico. Hasta hace poco, tener muelles en la web significaba una librería que calculaba cada fotograma en JavaScript. Con linear() se puede resolver la ecuación una sola vez, muestrearla y meter el resultado en una hoja de estilos. Esta lección deriva la ecuación, la convierte en código y es explícita sobre la única cosa que esta técnica no puede darte.
- Interpretar rigidez, amortiguación y masa como parámetros de diseño, no de física.
- Implementar la respuesta al escalón de un oscilador amortiguado en los tres regímenes.
- Calcular la duración natural de un muelle en lugar de inventarla.
- Enunciar con precisión qué pierde un muelle horneado frente a uno simulado.
La ecuación y sus tres regímenes
Un objeto de masa m unido a un muelle de rigidez k con un amortiguador de constante c obedece a la ecuación m·x'' + c·x' + k·x = 0 respecto a su posición de reposo. Lo que queremos es la respuesta al escalón: el objeto empieza en 0, el destino es 1, y parte con velocidad nula.
Es más cómodo trabajar con dos parámetros derivados, porque tienen significado directo:
- La frecuencia natural
w0 = sqrt(k/m), en radianes por segundo. Determina lo rápido que responde el sistema. - El factor de amortiguación
z = c / (2·sqrt(k·m)), sin unidades. Determina si oscila y cuánto.
El valor de z reparte el comportamiento en tres regímenes, y cada uno tiene una solución cerrada distinta.
Subamortiguado, z menor que 1. El sistema oscila alrededor del destino con amplitud decreciente. Es el que produce el rebote característico. Con wd = w0·sqrt(1 - z²):
x(t) = 1 - e^(-z·w0·t) · [ cos(wd·t) + (z·w0/wd)·sin(wd·t) ]
Críticamente amortiguado, z igual a 1. El sistema llega al destino en el menor tiempo posible sin pasarse ni una vez. Es el comportamiento de una puerta con amortiguador bien ajustado:
x(t) = 1 - e^(-w0·t) · (1 + w0·t)
Sobreamortiguado, z mayor que 1. El sistema llega lentamente sin oscilar, como si se moviera en un fluido viscoso. Con s = sqrt(z² - 1), r1 = -w0·(z - s) y r2 = -w0·(z + s):
x(t) = 1 - ( r2·e^(r1·t) - r1·e^(r2·t) ) / (r2 - r1)
Las tres cumplen x(0) = 0 y x'(0) = 0, que son las condiciones iniciales que queremos.
Los parámetros como decisiones de diseño
Traducir los tres números físicos a intención es lo que hace que esto sea utilizable.
| Parámetro | Efecto perceptivo | Rango útil |
|---|---|---|
Rigidez k |
Cuánta prisa tiene por llegar | 80 a 400 |
Amortiguación c |
Cuánto rebota | 10 a 40 |
Masa m |
Cuánto pesa, cuánto tarda en arrancar | 0.5 a 3 |
En la práctica se deja la masa a 1 y se juega con los otros dos, porque subir la masa equivale a bajar la rigidez y la amortiguación proporcionalmente.
El número que de verdad importa es z, porque es el que decide si hay rebote. Con masa 1, z = c / (2·sqrt(k)). Cuatro combinaciones que cubren casi todo:
k |
c |
z |
Se siente como |
|---|---|---|---|
| 180 | 26.8 | 1.00 | Firme, sin rebote |
| 180 | 20 | 0.75 | Un rebote apenas perceptible |
| 180 | 12 | 0.45 | Dos o tres rebotes visibles |
| 400 | 20 | 0.50 | Rápido y con rebote |
El generador
/**
* Genera una funcion de suavizado linear() que reproduce la respuesta
* al escalon de un muelle amortiguado, y su duracion natural en ms.
*/
function muelle({
rigidez = 180,
amortiguacion = 20,
masa = 1,
precision = 0.002, // umbral de reposo
puntos = 40,
decimales = 4,
} = {}) {
const w0 = Math.sqrt(rigidez / masa);
const z = amortiguacion / (2 * Math.sqrt(rigidez * masa));
let x;
if (z < 1) {
const wd = w0 * Math.sqrt(1 - z * z);
x = (t) =>
1 - Math.exp(-z * w0 * t) *
(Math.cos(wd * t) + ((z * w0) / wd) * Math.sin(wd * t));
} else if (z === 1) {
x = (t) => 1 - Math.exp(-w0 * t) * (1 + w0 * t);
} else {
const s = Math.sqrt(z * z - 1);
const r1 = -w0 * (z - s);
const r2 = -w0 * (z + s);
x = (t) =>
1 - (r2 * Math.exp(r1 * t) - r1 * Math.exp(r2 * t)) / (r2 - r1);
}
// Duracion natural: ultimo instante en que el error supera la precision.
const paso = 1 / 480;
let T = 0;
for (let t = 0; t <= 20; t += paso) {
if (Math.abs(1 - x(t)) > precision) T = t;
}
T += paso;
// Muestreo uniforme sobre la duracion natural.
const r = (v) => Number(v.toFixed(decimales));
const valores = [];
for (let i = 0; i < puntos; i++) {
const frac = i / (puntos - 1);
valores.push(i === 0 ? 0 : i === puntos - 1 ? 1 : r(x(frac * T)));
}
return {
easing: `linear(${valores.join(', ')})`,
duracion: Math.round(T * 1000),
z: r(z),
w0: r(w0),
};
}
const m = muelle({ rigidez: 180, amortiguacion: 20 });
console.log(m.duracion, 'ms');
console.log(m.easing);
Tres detalles del código merecen comentario.
La duración se calcula, no se elige. Un muelle tiene una duración natural que depende de sus parámetros: es el instante en que deja de moverse de forma perceptible. Elegirla a mano y forzar el muelle a caber dentro rompe la coherencia física, porque estás reescalando el tiempo. El bucle busca el último instante en que el error supera el umbral, y esa es la duración.
El umbral de precisión decide dónde cortas la cola. Un muelle nunca llega exactamente al destino: se acerca asintóticamente. Con 0.002 cortas cuando está a dos milésimas, que sobre un desplazamiento de 300 píxeles son 0.6 píxeles, por debajo de lo perceptible. Bajarlo alarga la animación sin que se vea nada; subirlo produce un corte visible.
Los extremos se fuerzan a 0 y 1. El primer valor es exactamente 0 por construcción, pero el último no lo sería por el corte asintótico, y un valor final de 0.998 haría que el elemento no llegara del todo a su destino.
El uso, con la duración que el propio muelle dicta:
const { easing, duracion } = muelle({ rigidez: 220, amortiguacion: 18 });
panel.animate(
[{ translate: '0 24px', opacity: 0 }, { translate: '0 0', opacity: 1 }],
{ duration: duracion, easing, fill: 'forwards' }
);
Y en CSS, con las dos como variables generadas en tiempo de compilación:
/* Generado con rigidez 180, amortiguacion 20, masa 1, 32 puntos. */
:root {
--muelle-suave: linear(
0, 0.0273, 0.0963, 0.1903, 0.2969, 0.4067, 0.513, 0.6114,
0.6993, 0.7754, 0.8395, 0.892, 0.9338, 0.966, 0.99, 1.0071,
1.0185, 1.0254, 1.0289, 1.0298, 1.0289, 1.0268, 1.024, 1.0208,
1.0175, 1.0143, 1.0114, 1.0088, 1.0066, 1.0047, 1.0031, 1
);
--muelle-dur: 575ms;
}
Fíjate en que el valor máximo es 1.0298, es decir un sobrepaso de menos del 3 por ciento: exactamente el umbral que la lección de continuación marcaba como frontera entre inercia y espectáculo. Un muelle con un factor de amortiguación de 0.75 produce ese número por física, no por gusto, y esa coincidencia es la razón de que los muelles bien parametrizados se sientan bien sin tener que ajustar nada a ojo.
Esta es la limitación que hay que entender antes de decidir si linear() sustituye a una librería de muelles, porque no es una cuestión de precisión sino de naturaleza. Un muelle simulado integra el estado físico en cada fotograma: tiene una posición y una velocidad, y si lo interrumpes a mitad y le cambias el destino, la nueva trayectoria arranca con la velocidad que llevaba. Eso es exactamente lo que hace que un panel arrastrable se sienta real: lo sueltas a media velocidad y sigue, no se para para volver a arrancar. Un muelle horneado en linear() es una tabla de valores en función de una fracción de tiempo: no tiene velocidad, tiene una posición por instante. Si lo interrumpes, la nueva animación empieza en su valor inicial con la velocidad que dicte su curva, que es cero. El tirón es visible y no hay forma de evitarlo con esta técnica. La consecuencia es una frontera nítida y fácil de aplicar: para movimientos disparados por un cambio de estado, la técnica de hornear es correcta y superior, porque las interrupciones son raras y el coste en ejecución es nulo; para movimientos que continúan un gesto del usuario, no sirve, y ahí sigue haciendo falta un integrador de verdad. Hay un matiz que salva algunos casos intermedios: si conoces la velocidad en el momento de la interrupción —porque la puedes medir—, puedes generar en ese instante una nueva cadena linear() con las condiciones iniciales correctas, es decir, resolver la ecuación con velocidad inicial distinta de cero. Es más trabajo y funciona, y es exactamente lo que hacen las librerías que dicen tener muelles en CSS: no ejecutan física en cada fotograma, la ejecutan en cada interrupción.
Sobre la propuesta de función spring()
Existe una propuesta de función de suavizado spring() en el borrador de CSS Easing Level 2, que permitiría escribir los parámetros del muelle directamente en CSS sin generar puntos. Es un borrador de editores y a agosto de 2026 no es una capacidad con la que se pueda contar en producción. La técnica portable sigue siendo generar linear(), que además tiene la ventaja de funcionar hoy en los cuatro motores.
Un pequeño catálogo
Para tener un punto de partida sin recalcular cada vez, cuatro muelles con sus parámetros, listos para pasar al generador:
| Nombre | Rigidez | Amortiguación | z |
Duración | Uso |
|---|---|---|---|---|---|
| Firme | 200 | 28 | 0.99 | 581 ms | Paneles, diálogos |
| Vivo | 260 | 22 | 0.68 | 594 ms | Menús, listas |
| Juguetón | 180 | 12 | 0.45 | 919 ms | Confirmaciones, celebraciones |
| Pesado | 90 | 20 | 1.05 | 1017 ms | Elementos grandes |
Las duraciones salen del generador con el umbral de precisión de 0.002; subirlo las acorta y bajarlo las alarga, sin cambiar el aspecto del movimiento. Fíjate en que el muelle pesado está ligeramente sobreamortiguado: no rebota nada y aun así tarda un segundo, que es exactamente lo que hace un objeto de mucha masa.
- Genera los cuatro muelles del catálogo y anota la duración que calcula el generador para cada uno.
- Aplícalos a un mismo desplazamiento de 200 píxeles y ordénalos por cuál se siente más natural para un panel.
- Baja el número de puntos de 40 a 12 en el más oscilante y localiza en qué número empieza a verse anguloso.
- Interrumpe uno a mitad cambiando el destino y describe exactamente qué se ve. Compáralo con lo que haría un muelle simulado.