· 5 min read
Turning a spring into CSS
A spring has no duration and no curve you can name, and CSS wants both. How verve samples one into a linear() easing, how the stops get chosen, and the parts that don't survive the trip.
A spring doesn't have a duration. You give it stiffness, damping and mass, and it takes however long it takes to get where it's going. It doesn't have a named curve either, which is the other half of the problem, because CSS animations want exactly those two things: a duration in milliseconds and an easing function.
For a long time the workaround was to bake the motion into the keyframes themselves. Sample the spring, write forty percentage stops, let the browser interpolate between them. It works and it reads horribly, and the moment you want to move the element somewhere else you regenerate all forty.
There's a better answer now, and it's the one verve's CSS export uses.
The export
This is real output, for a card sliding in from 240px left over 800ms with verve's snappy spring on the first keyframe:
.card {
animation:
card-transform 800ms 0ms both;
}
@keyframes card-transform {
0% {
transform: translate3d(-240px, 0px, 0);
animation-timing-function: linear(0, 0.004 0.7%, 0.026 1.9%, 0.061 3%, 0.108 4.1%, 0.201 6%, 0.454 10.5%, 0.574 12.7%, 0.68 15%, 0.771 17.2%, 0.846 19.5%, 0.913 22.1%, 0.961 24.7%, 0.994 27.3%, 1.01 29.2%, 1.022 31.5%, 1.03 36.3%, 1.007 53.2%, 1.001 61%, 1);
}
100% {
transform: translate3d(0px, 0px, 0);
}
}Two keyframes. The spring is the easing function.
linear() takes a list of progress values with optional positions, and interpolates straight lines between them. Enough of them, in the right places, and a straight-line approximation is indistinguishable from the curve. It's an ugly-looking function that does something genuinely useful: it lets you describe any easing at all, including ones with no closed form and no bezier that fits.
Notice the values above 1. Progress 1.03 at 36.3% of the way through means the card is 3% past where it's going, which is the overshoot. CSS has no problem with that. The stop list isn't required to be monotonic, so a spring's entire trip out and back is just numbers.
Where the stops go
The stops aren't evenly spaced, and that's the whole trick. Look at the gaps: six stops in the first 6% of the animation, then nothing between 36.3% and 53.2%. The spring does almost everything it's going to do early and then creeps the last 3% home, so that's where the detail is spent.
Getting there is two steps.
First, sample densely enough for the spring's own frequency. A fixed sample count aliases, and how badly depends entirely on the spring: the count that reconstructs a typical 800ms snappy segment to within 2.4% comes back 27% off on a spring at stiffness 400, damping 6 and mass 0.5 running over two seconds, because that one oscillates several times inside the window. So the count isn't fixed. verve works out the damped frequency from the config and takes twelve samples per cycle, with a floor of 24 and a ceiling of 240.
Second, throw away every sample that a straight line already predicts. That's Douglas–Peucker, run on the sampled progress values, keeping any point further than 0.25% of the travel from the chord between its neighbours. The distance it measures is vertical rather than perpendicular, which matters more than it sounds: vertical distance is exactly the error you get when a consumer linearly interpolates between the points it kept, and linear interpolation between stops is precisely what linear() does. Bounding that number bounds the real error rather than a proxy for it.
For the snappy spring above, that's 20 stops. bouncy, which crosses the target several times, keeps 34 — abbreviated here, because the full list is exactly as long as it sounds:
animation-timing-function: linear(0, 0.009 1.1%, 0.023 1.9%, /* … */ 1.246 27.7%, 1.249 29.2%, 1.244 30.7%, /* … */ 0.943 54.7%, 0.938 58.1%, 0.943 61.4%, /* … */ 1.005 100%, 1);It peaks at 1.249, a quarter of the distance past the target, then undershoots to 0.938 on the way back before settling. Every wobble is in there, and it costs 14 more stops than the spring that doesn't wobble.
Does it actually match
verve runs a gate that renders the exported CSS in a real browser, sets currentTime at 41 moments across the animation, and compares the resolved transform matrix against the engine's own value at the same instants. Across every fixture it covers, the worst disagreement in the last run was 0.46% of the property's range, against a tolerance of 0.6%.
That's a sampled curve pretending to be a continuous one, and it's off by a fraction of a percent. Which is the point of bounding the error at sampling time rather than hoping.
What doesn't survive
Three things, and the third is the one worth knowing.
The spring picks the duration in CSS, not physics. The engine cuts a spring at the next keyframe, so what you see in the editor is the spring for that span and nothing after. The export copies that faithfully, including the case where the spring is still visibly moving when the segment ends: linear() writes two stops at the same position so the value jumps there instead of ramping into it. But it means the duration is a number you chose, not one the physics produced.
Old browsers get a different curve. linear() landed in Chrome and Firefox in early 2023 and in Safari at the end of it. Anywhere it isn't supported the declaration is invalid and gets dropped, so the animation still runs for the right 800ms with the browser's default ease instead of your spring. It degrades to something reasonable rather than breaking, but it isn't your animation.
A sampled spring can't be interrupted properly. This is the real limit. A live spring, the kind Motion runs, knows its current velocity, so if you retarget it halfway it carries that velocity into the new motion and the handoff looks continuous. A linear() easing is a recording. Interrupt it and restart it and it begins at the start of the curve, from a standstill, wherever the element happens to be.
For an entrance, a reveal or anything else that plays once and finishes, none of that matters and you get spring motion out of a stylesheet with nothing to install. For a drag handle or a value that changes while it's still moving, you want a real spring at runtime, which is what the Motion export is for.
Picking between them is mostly that one question: does this animation ever get interrupted?
Try it
Open the editor, put a spring on a keyframe, tune it, then open the export and switch to CSS. Take the damping down, export again, and watch the stop count climb as the spring gains wobbles to describe. The motion specimen is the shorter version if you only want to see the curves.