TL;DR
- Stiffness, damping and mass only matter in two combinations: natural frequency (speed) and damping ratio (bounce).
- Pick the damping ratio from the overshoot you can tolerate, then set stiffness and mass to hit the duration.
- Use a spring when motion can be interrupted. Use
linear()in CSS when it cannot.
Every drawer animation I have shipped has been tuned twice: once by whoever designed it, and once by whoever watched it on a phone. The reason is almost always the same. A cubic-bezier curve describes a shape, so it cannot tell you why 240ms feels sluggish while 200ms feels abrupt. A spring describes a force, and forces have units you can reason about.
The good news is that you do not need the differential equation to use one. You need two derived numbers, and a way to read them.
The three dials, and the two numbers that matter
A spring-driven UI element is a mass on a spring with friction:
- Stiffness (
k, N/m) pulls the element toward its target. Higher means it accelerates harder. - Damping (
c, Ns/m) removes energy. Higher means it stops bouncing and eventually crawls. - Mass (
m, kg) resists acceleration. Higher means everything happens later.
Those three collapse into two:
ω₀ = √(k/m) natural frequency, rad/s — how fast
ζ = c / (2√(km)) damping ratio, unitless — how bouncy
This is the whole game. Doubling stiffness and halving mass both raise ω₀, and they feel different mainly because of acceleration, not duration. Once you know ω₀ and ζ, you know the overshoot:
overshoot ≈ exp(−πζ / √(1 − ζ²))
At ζ = 1 there is no overshoot at all. At ζ = 0.5 you get about 16%. At ζ = 0.25, about 44% — which reads as a bug in most interfaces.
Read the damping ratio, not the sliders
The sliders are not portable. stiffness: 220, damping: 18, mass: 1 is a specific feel; stiffness: 400, damping: 24, mass: 0.55 is close but not identical. ζ is the portable description.
| ζ | Overshoot | Feels like | Where it belongs |
|---|---|---|---|
| 0.2 | ~52% | Wobbly, almost comic | One hero moment, once |
| 0.4 | ~25% | Bouncy | Toggles, playful cards |
| 0.6 | ~9% | Springy but controlled | Hover reveals, sheets |
| 0.75 | ~3% | Crisp | Menus, page transitions |
| 1.0 | 0% | Settled, slightly heavy | Large panels, drag release |
| 1.3 | 0% | Slow, deliberate | Full-screen overlays |
Two rules of thumb that have held up for me:
- Anything the user triggers repeatedly stays under 10% overshoot. Overshoot on the third tap of the same button stops reading as personality.
- Motion larger than about 400px should not overshoot at all. A full-screen sheet that bounces past the viewport edge looks broken, not springy.
A worked example: the 320ms drawer
Say the design review says “quick, with a little life”. That translates to roughly 300–350ms and under 5% overshoot.
From the table, 4% overshoot is ζ ≈ 0.72. The 2% settling time of a spring is close to 4 / (ζω₀), so for 320ms:
ω₀ = 4 / (0.72 × 0.32) ≈ 17.4 rad/s
Pick m = 1 for convenience, then:
k = m × ω₀² ≈ 300
c = 2ζ√(km) ≈ 25
So stiffness: 300, damping: 25, mass: 1. That is the number I would hand to a developer, and it survives a redesign of the component far better than “make it snappier”.
If it turns out to be right but too slow, raise ω₀ rather than fudging the damping: k = 420, c = 30 keeps ζ at 0.73 and cuts the settle time by about 20%. Changing damping instead would change the character, which is rarely what you meant.
Mass is the dial everyone forgets
Mass looks redundant because you can imitate its effect with stiffness, and for duration that is true. What it changes is the acceleration profile. A very stiff, very light spring snaps off the mark and then stops hard, which suits small elements. A heavier spring on the same duration eases in and out of the movement, which is why large surfaces almost always feel better with more mass.
My default when something feels “too digital” is to raise mass by 50% and adjust stiffness to keep ω₀. Nine times out of ten it reads as more expensive without being slower. The tenth time it feels sluggish and I put it back.
Interruption is the actual reason to use a spring
Here is the case that sold me. A drawer is 60% open when the user taps close. With a CSS transition, the element restarts from its current position with zero velocity, and the direction change is a visible hiccup. With a spring, the starting velocity is already 1200px/s outward, so the motion decelerates, turns, and comes back. Same duration, completely different intelligence.
That property is why springs are worth the extra dependency for anything tappable twice: drawers, toasts, expanded rows, bottom sheets, hover states on touch devices.
When CSS is enough
linear() easing, supported across Chrome, Safari and Firefox since 2023, lets you write a sampled spring as a CSS transition:
.sheet {
transition: transform 420ms linear(0, 0.19, 0.62, 0.92, 1.06, 1.09, 1.05, 1.01, 1);
}
.sheet[data-open='true'] {
transform: translateY(0);
}
It reproduces the curve, including overshoot (values over 1 are allowed). What it does not do is carry velocity: interrupt it halfway and the timing function restarts from the new starting point. For a menu that opens once, nobody will ever notice. For the drawer above, they will.
So the decision is not “springs are modern, curves are old”. It is one question: can this motion be interrupted before it finishes? If yes, use the library. If no, save the JavaScript.
Testing motion without fooling yourself
- Watch it at 120Hz as well as 60Hz. Springs tuned on a 60Hz display look slightly snappier on a high-refresh phone, and values that felt right there can feel loose.
- Test with
prefers-reduced-motion: reduceon. The honest implementation is an instant state change, not a faster animation. - Put it on a real mid-range Android. Stiff springs on a long list are fine; the same spring on a
backdrop-filterpanel is where frames disappear. - If it only feels right in a demo with one element, it will feel wrong in a list of twelve. Springs desynchronise, which is nice for a toggle and nauseating for a grid.
I built a playground for this because doing the algebra above by hand every time was silly. It shows ω₀, ζ, overshoot and settle time as you drag, and exports either a Motion transition or a linear() transition. The maths in the tool is the same as the maths in this post, which is the point: if the preview and the code ever disagree, the code is wrong.
One last thing I would tell my past self: write the numbers down in the component, not in the design file. transition={{ type: 'spring', stiffness: 300, damping: 25, mass: 1 }} with a comment explaining the 4% overshoot target is documentation. A Figma prototype is not.