How a spring becomes a line of CSS
Springs used to need a JavaScript animation library. Since 2023, every major browser can run them from a single CSS timing function. This is how Ease gets from physics to that function, and where the trade-offs are.
1. A spring is an equation, not a curve
A spring pulls an object toward its target with a force proportional to the distance (stiffness), while friction slows it down (damping). Starting at rest, the remaining distance d(t) for an under-damped spring is:
d(t) = exp(-zeta * w0 * t) * ( cos(wd * t) + (zeta * w0 / wd) * sin(wd * t) )
w0 = sqrt(stiffness / mass) natural frequency
zeta = damping / (2 * sqrt(stiffness * mass)) damping ratio
wd = w0 * sqrt(1 - zeta^2) damped frequency
progress(t) = 1 - d(t)Stiffness and damping are hard to reason about, so Ease uses the perceptual model popularised by SwiftUI: duration (the period of the undamped spring) and bounce. Bounce 0 is critically damped, positive values overshoot, and negative values over-damp. The mapping is exact and reversible, and it is unit-tested against a numerical (RK4) simulation of the same physics.
- settles
- 819ms
- overshoot
- 0.046
2. Why cubic-bezier() can't do it
A cubic-bezier() is a single cubic curve with fixed endpoints. Push a control point above 1 and you get one overshoot. That is the classic "back" ease, but you can never get the overshoot and the settle back and a second, smaller wobble. Real springs oscillate with decaying amplitude, which a single cubic cannot represent.
3. Enter linear()
linear() defines an easing as a list of points joined by straight lines. With enough points it can approximate any shape, including a spring. Each stop is an output value and, optionally, the input progress where it occurs:
transition: transform 607ms linear(0, 0.0253 2.83%, 0.0983 6%, 0.5354 18.83%, 0.7161 25.17%,
0.8434 31.33%, 0.9321 38%, 0.9868 45.67%, 1.012 55.17%, 1);The browser interpolates between stops, so the question becomes which points to keep, and how many.
4. Sampling and simplification
Ease samples the spring at 600 evenly spaced moments, then simplifies with the Ramer–Douglas–Peucker algorithm. It recursively keeps the point that deviates most from a straight line until every remaining point is within a tolerance. Ease measures that deviation vertically (value error at a given time) rather than perpendicularly, because value error is what you actually see.
Raise the tolerance below and watch stops disappear from the straight parts of the curve while the turns stay dense. The reported max error is measured against the true curve at 2,000 points, not estimated.
- settles
- 1005ms
- stops
- 10
- max error
- 0.0196
- chars
- 125
linear(0, 0.0337 2.17%, 0.1291 4.5%, 0.6876 13.5%, 0.8926 17.5%, 1.0307 21.5%, 1.1077 25.83%, 1.1212 32.17%, 0.987 55.17%, 1)At the "Mid" setting (±0.005), the error on a 300px movement is about 1.5px at worst, below what most people can perceive in motion, and a typical spring needs 10–20 stops. The "High" setting is for very large movements.
5. Duration is the settle time
A spring never technically arrives, because the exponential decays forever. CSS needs a finite duration, so Ease computes the time after which the spring stays within 0.1% of its target and uses that as the animation's duration. This is why a spring with a perceived duration of 0.5s exports as roughly 0.6–1s depending on bounce. The tail is long but tiny.
6. Support and fallbacks
linear() shipped in Chrome 113, Firefox 112 and Safari 17.2, and has been Baseline since December 2023. For older browsers, Ease's CSS export includes a fallback that swaps in ease-out only where linear() is unsupported:
@supports not (transition-timing-function: linear(0, 1)) {
:root { --ease-spring: ease-out; }
}One limitation is real: a CSS spring cannot inherit velocity when it is interrupted midway, the way a physics-driven JavaScript spring can. For gesture-driven motion, use a library. For state transitions, which are most interface motion, pure CSS is enough.
Try it on real UI
Here is a spring with bounce 0.3, the default in the editor:
How to read it
Top row: where the element sits on each frame, so bunched ticks mean slow motion. Bottom row: the same frames in time order, each bar the distance travelled, so the settle is easy to read. Keys are circled.
Publish changes?
Your edits will be visible to everyone.