A spring in the easing function
CaveatBoth knobs leave together and both take 550ms; only the lower one arrives like an object with mass. linear() is not a curve — it is a polyline the engine walks between the stops you hand it, so a spring is a real spring equation sampled into two dozen of them. That is the first thing it costs: nobody writes this by hand, and a stop count low enough to read is low enough to see, as facets in what should be smooth. The second is that it is a timing function over a fixed duration and not physics, so it has no state. Toggle twice quickly and it does not carry its velocity into the new run the way a spring would; it restarts along the curve from wherever it stood, and the overshoot lands somewhere the eye reads as a fault. And the overshoot only exists on properties that can pass their end value: translate and transform can, opacity clamps at 1, so a bounce written on a fade is quietly just a fade.
FallbackWidely available since 2024; where it is not understood the value is dropped and the easing falls back to plain ease — the motion still happens, without the character. The real trap is the shorthand: written as transition: translate .55s linear(…), a rejected value takes the whole declaration with it, so the transition disappears rather than degrading. Where that matters, keep the shorthand for property and duration and set transition-timing-function on a line of its own. And it belongs inside prefers-reduced-motion: an overshoot is precisely the thing people switch motion off to be rid of.
<div class="lane"><i class="knob"></i></div>
/* generated from mass, stiffness and
damping — not typed by hand */
.knob {
transition: translate .55s linear(
0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%,
0.723 12.9%, 0.938 16.7%, 1.017, 1.077,
1.121, 1.149 24.3%, 1.159, 1.163, 1.161,
1.154 29.9%, 1.129 32.8%, 1.051 39.6%,
1.017 43.1%, 0.991, 0.977 51%, 0.974 53.8%,
0.975 57.1%, 0.997 69.8%, 1.003 76.9%, 1);
}
/* values over 1 are the overshoot */
.demo:has(input:checked) .knob { translate: 185px; }