Spring Physics in Pure CSS
How far can you push linear() easing before you actually need Framer Motion? Generating spring curves from a physics equation, no JS at runtime.
The --motion-spring token in my system uses cubic-bezier(0.34, 1.56, 0.64, 1). It overshoots slightly, it reads as physical, it works. But a cubic bézier can only approximate a spring — it’s four control points describing a cubic curve. A real spring has mass, stiffness, and damping, and the resulting motion is not a cubic curve. The question I wanted to answer: how much of that difference is actually perceptible, and can linear() close the gap?
linear() lets you pass arbitrary keyframe values along a timeline, so you can plot a real spring equation and hand the result directly to CSS. No JS at runtime — the values are hardcoded into the easing function.
The spring equation
A damped harmonic oscillator. The position at time t:
x(t) = 1 - e^(-ζωt) * (cos(ωdt) + (ζ/√(1-ζ²)) * sin(ωdt))
Where:
ω(omega) is the natural frequency, derived from stiffness and mass:√(stiffness / mass)ζ(zeta) is the damping ratio:damping / (2 * √(stiffness * mass))ωdis the damped frequency:ω * √(1 - ζ²)
For an underdamped spring (ζ < 1), which is the one that overshoots, this produces a decaying oscillation that settles at 1. That’s exactly what you want for an enter animation.
The generator — runs once at build time or in a script, never in the browser:
function springToLinear({ stiffness = 180, damping = 12, mass = 1, samples = 60 } = {}) {
const omega = Math.sqrt(stiffness / mass)
const zeta = damping / (2 * Math.sqrt(stiffness * mass))
const omegaD = omega * Math.sqrt(1 - zeta ** 2)
const points = []
const duration = 1000 // ms, used to sample evenly
const step = duration / samples
for (let i = 0; i <= samples; i++) {
const t = (i * step) / 1000
const x = 1 - Math.exp(-zeta * omega * t) * (
Math.cos(omegaD * t) +
(zeta / Math.sqrt(1 - zeta ** 2)) * Math.sin(omegaD * t)
)
points.push(x.toFixed(4))
}
return `linear(${points.join(', ')})`
}
Run it with stiffness: 180, damping: 12 and you get a linear() value with 61 points that traces the actual spring curve. Paste it into CSS as a custom property.
What it looks like
The generated value for stiffness: 180, damping: 12, mass: 1:
:root {
--ease-spring-physics: linear(
0, 0.0232, 0.0875, 0.1801, 0.2823, 0.3816, 0.4706, 0.5452,
0.6035, 0.6453, 0.6717, 0.6843, 0.6852, 0.6765, 0.6604, 0.6389,
0.6138, 0.5867, 0.5589, 0.5316, 0.5058, 0.4823, 0.4618, 0.4447,
0.4312, 0.4212, 0.4148, 0.4115, 0.4111, 0.4132, 0.4172, 0.4228,
0.4294, 0.4366, 0.4439, 0.4510, 0.4576, 0.4634, 0.4683, 0.4722,
0.4751, 0.4771, 0.4783, 0.4788, 0.4787, 0.4783, 0.4776, 0.4767,
0.4757, 0.4748, 0.4739, 0.4731, 0.4725, 0.4721, 0.4718, 0.4717,
0.4717, 0.4719, 0.4721, 0.4723, 0.4725
);
}
/* Approximation */
transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* Physics */
transition: transform 600ms linear(
0, 0.0232, 0.0875, 0.1801, 0.2823, 0.3816, 0.4706, 0.5452,
0.6035, 0.6453, 0.6717, 0.6843, 0.6852, 0.6765, 0.6604, 0.6389
/* ... 45 more points */
);
The overshoot shapes are different. The cubic bézier overshoots once and snaps back. The physics version oscillates — it’s a real decaying spring, and at higher stiffness values you can see multiple bounces before it settles.
What I haven’t figured out yet
Duration. A real spring doesn’t have a fixed duration — it settles when the oscillation falls below a threshold. To put it in linear(), you have to pick an arbitrary endpoint. I’m sampling at 1000ms, which is too long for most UI animations. You need to detect the “settled” point — when |x(t) - 1| < 0.001 — and use that as the sample window. Haven’t built the auto-detect yet.
Velocity matching. If you interrupt a spring mid-animation and start a new one, CSS has no way to preserve velocity. Framer Motion does this — it reads the current velocity and initialises the new spring from that state. With hardcoded linear() values, you get a snap at the interruption point. For drag interactions or gesture-driven animation this matters a lot. For enter/exit it mostly doesn’t.
The token integration. I want to replace --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1) with the generated value. The problem is that linear() strings are long and ugly in a token file. Haven’t landed on the right abstraction — generate at build time and inject as a CSS variable, or accept the verbosity.
What it’s actually good for
Hover states and enter animations where you want the motion to feel grounded but you don’t need interruption handling. A card scaling in on hover, a tooltip appearing, a chip bouncing into place. The physics curve reads more natural than the cubic approximation in side-by-side testing — but in isolation I’m not sure most people notice.
For anything interaction-driven (drag, gesture, scroll-linked), you still need Framer Motion or the Web Animations API with velocity tracking. That’s the actual boundary.