← Back
[W] Writing

What a spring animation actually is

Most animations are a curve you pick in advance. A spring is a different kind of thing. It's a small physics simulation that the interface runs on every frame.

You only really see the difference when an animation gets interrupted. Click or tap anywhere in the box to send the ball somewhere new, then do it again while it's still moving.

Try both modes. The graph underneath is the ball's speed. With the tween, every retarget makes the line fall off a cliff. With the spring, it bends.

The tween stops dead and starts again. The spring swings round towards the new target, and you never see a corner.

A curve versus a simulation

A tween, which is what CSS transitions and most "duration and easing" animations are, is a function of time. Give it a start, an end, 600ms and ease-in-out, and it can tell you where the ball is at any moment before it has even started. Progress is the only input.

That's also its weakness. When the target changes halfway through, the old function is useless, so the usual fix is to start a new tween from wherever the ball is now. The new tween starts from rest, because every ease-in-out curve does. The ball was doing 800 px/s a frame ago, and now it's doing almost nothing.

Your eye reads that drop in speed as a jolt, even though the position never jumps.

A spring doesn't have a timeline at all. It has a state, which is a position and a velocity, and a rule for how that state changes. Change the target and the rule simply pulls towards the new place. The velocity is still there, so the motion carries on.

Apple's WWDC23 session on springs puts it this way: a retargeted spring "uses the velocity it had when it was retargeted as the initial velocity towards its new destination."

The whole simulation

The rule is two forces from school physics.

  • The spring force, Hooke's law: F = −k·x. Here x is how far you are from the target, and k is the stiffness. Further away means a harder pull back.
  • The damping force: F = −c·v. It pushes against the velocity v, like moving through syrup. c is how thick the syrup is.

Add them up, divide by the mass m, and you have the acceleration. Every frame, you nudge the velocity by the acceleration, then nudge the position by the velocity. That's it. This is the step the demo above runs. It fixes the mass at 1, as Apple does, and gets k and c from two friendlier numbers that come up further down:

function step(s, target, dt) {
  const k = (2 * Math.PI / response) ** 2;
  const c = (4 * Math.PI * dampingFraction) / response;
  const a = -k * (s.x - target) - c * s.v;
  s.v += a * dt;   // velocity first,
  s.x += s.v * dt; // then position, using the new velocity
}

The order of those last two lines matters. It's called semi-implicit Euler. If you update the position with the old velocity instead, the simulation slowly gains energy, and a stiff spring can wobble more and more until it flies off.

The other detail is dt, the time step. Frames don't arrive evenly. A dropped frame might hand you 50ms at once, and one big step on a stiff spring overshoots wildly. So the demo cuts every frame into fixed 1ms slices and runs the step once per slice, however long the frame took. The motion then comes out the same on a 60Hz laptop and a 120Hz phone.

There's also a closed-form answer, a formula built from exponentials and cosines that gives the exact position at any time. SwiftUI's Spring type exposes it as value(target:time:). It's handy for plotting, and the chart in the tuner further down uses it. Stepping is easier to interrupt, because the state is just two numbers you keep.

Three ways to settle

How a spring behaves depends on one number, the damping ratio:

ζ = c / (2·√(k·m))

It compares the damping you have with the exact amount that stops the spring overshooting.

target start time, 1.6 s → Underdamped, ζ = 0.3: overshoots 37% Critically damped, ζ = 1 Overdamped, ζ = 2
Same stiffness, three amounts of damping. Below 1 it bounces. At exactly 1 it arrives as fast as it can without crossing the target. Above 1 it's sluggish.
  • Underdamped, ζ below 1. It overshoots and oscillates, and each swing is smaller. The overshoot is predictable: e^(−πζ/√(1−ζ²)) of the distance. At ζ = 0.7, that's 4.6%.
  • Critically damped, ζ = 1. It's the fastest way in with no overshoot. Most interface motion wants to be here or just below.
  • Overdamped, ζ above 1. It creeps up on the target. It's rarely what you want, except for something heavy.

Parameters a designer can tune

Stiffness and damping are honest physics, but they're miserable to tune. Change the stiffness and the damping ratio changes with it, so a spring you'd got feeling right starts bouncing.

Apple's answer, shown in the 2018 talk Designing Fluid Interfaces, is two different knobs. Response is roughly how long one swing takes. It sets the speed. Damping fraction is ζ itself. It sets the bounce. SwiftUI's .spring(response:dampingFraction:) describes the second one as "a fraction of an estimate of amount needed to produce critical damping".

At WWDC23 Apple introduced a newer pair, duration and bounce. Duration is the same number as response. Bounce is 1 − ζ, so a bounce of 0 is critically damped. With the mass fixed at 1, the conversion is:

const stiffness = (2 * Math.PI / duration) ** 2;
const damping = (4 * Math.PI * (1 - bounce)) / duration;

Apple's own documentation checks it: Spring(duration: 0.5, bounce: 0.3) comes out at stiffness 157.9 and damping 17.6. Negative bounce gives an overdamped spring, and Apple maps that range differently, so the formula above is only for a bounce of 0 and up.

The plot is position over time for a move from 0 to 1, drawn from the closed-form solution. Play runs the stepped simulation instead. The red line marks where the spring has settled, which means it stays within 0.1% of the target from then on.

Motion has the same idea under different names: visualDuration and bounce. Its docs also point out the trap. A spring set with a plain duration "starts from rest and ignores velocity", which throws away the one thing that made it a spring.

Why a spring has no fixed length

Mathematically, a spring never arrives. The wobble shrinks by a constant factor every moment, but it never reaches zero. So every spring library decides when it's close enough. It stops when the distance and the speed both fall below a threshold, which Motion calls restDelta and restSpeed.

That's why the duration you set isn't the time it runs for. Apple calls the number you set a "perceptual duration", meaning about when it looks finished. The time until it can actually be removed is the settling duration, and it depends on the bounce and the distance. Ask SwiftUI's Spring for the settling duration of duration: 0.5, bounce: 0.3 and it says about 1.04 seconds.

So don't chain the next thing onto the end of a spring. The tail is long and mostly invisible. Hang it off the perceptual duration, or better, off nothing at all, and let the next spring take over the velocity this one leaves behind.

Next