Chapter 4

Spring Physics

The damped harmonic oscillator behind modern motion.

rAF integratorMotion spring()Svelte Springlinear()
Stiffness, damping, mass → acceleration, integrated per frame. Springs have no fixed duration and inherit velocity on interruption — which is why they feel right for gestures.

Duration-based easing answers “how should A travel to B over N milliseconds?”. A spring refuses the question. It has no timeline — it has physics. Give it a target and it accelerates, overshoots, settles, and (crucially) inherits its velocity if you interrupt it. That single property is why modern motion feels alive under your finger.

A spring is a damped harmonic oscillator

Every frame, two forces act on the object. Hooke's law pulls it toward the target proportional to how far away it is; a damping force opposes its velocity, bleeding off energy so it doesn't oscillate forever. Newton turns the net force into acceleration:

F = −k·x  −  c·v
a = F / m
x = displacement from target · v = velocity · k stiffness · c damping · m mass

To animate it we integrate per frame. We use semi-implicit (symplectic) Euler: update velocity from the current acceleration first, then move the position with that new velocity. It's one extra line versus naïve Euler and dramatically more stable for stiff springs:

const a = (-k*(x - target) - c*v) / m;  // acceleration
v += a * dt;                            // velocity FIRST
x += v * dt;                            // then position

The lab: tune the spring, watch ζ

This object is driven by a hand-rolled requestAnimationFrame integrator — the exact stepSpring() above, no library. Click or drag anywhere to set a target. Drop damping for bounce, raise it past critical for sludge. The badge shows the live damping ratio and classification.

click or drag anywhere
ζ = 0.500 · under-damped
damping ratio ζ0.500
classificationunder-damped
ζ = c / (2·√(k·m))
position vs time — current params
1.00.00.70st

The curve is computed by the same sampleSpring() integrator that drives the box. Drag damping down to see overshoot/oscillation (ζ<1); raise it past critical (ζ=1) for a slow, monotonic approach (ζ>1).

Velocity inheritance, isolated

Same idea, stripped down. Flick the object to give it momentum, then retarget while it's still moving. With inheritance on, it banks into the new target like a thrown ball; off, it forgets its motion and lurches. This is the property gestures and drag-release depend on.

Flick mid-flight live demo
↑ scroll up to the lab — flick, then retarget mid-flight

Use the flick button in the lab above, then immediately click a new target. The same inherit velocity toggle governs both. There is no separate engine here — velocity inheritance falls out for free from never resetting v.

The designer-friendly model: duration + bounce

Thinking in raw stiffness and damping is unintuitive. Apple's SwiftUI (and Motion's spring()) expose a perceptual reparameterisation: a perceived duration and a bounce from 0 to 1, converted to physics under the hood with mass fixed at 1:

mass = 1
stiffness = (2π / duration)²
damping = (1 − bounce) · 4π / duration
bounce 0 → critically damped · bounce → 1 → very springy. e.g. duration 0.5s, bounce 0.3 → k≈158, c≈17.6, m=1

Baking a spring into CSS with linear()

The 2023 linear() easing function lets you approximate any curve — including a spring — with a long list of sampled points. We sample the spring until it settles and emit each point as progress percent%. The result runs as pure CSS, even on the compositor.

linear() easing Baseline 2024
Chrome/Edge
Firefox
Safari

Widely available since 2024; the practical way to ship a 'spring' as plain CSS.

animation-timing-function:
linear(0.0000 0.00%, 0.0205 2.13%, 0.0595 4.26%, 0.1132 6.38%, 0.1783 8.51%, 0.2518 10.64%, 0.3307 12.77%, 0.4126 14.89%, 0.4945 17.02%, 0.5746 19.15%, 0.6514 21.28%, 0.7238 23.40%, 0.7910 25.53%, 0.8523 27.66%, 0.9065 29.79%, 0.9536 31.91%, 0.9942 34.04%, 1.0284 36.17%, 1.0565 38.30%, 1.0788 40.43%, 1.0958 42.55%, 1.1071 44.68%, 1.1141 46.81%, 1.1175 48.94%, 1.1176 51.06%, 1.1151 53.19%, 1.1103 55.32%, 1.1037 57.45%, 1.0956 59.57%, 1.0867 61.70%, 1.0773 63.83%, 1.0676 65.96%, 1.0579 68.09%, 1.0485 70.21%, 1.0394 72.34%, 1.0310 74.47%, 1.0233 76.60%, 1.0163 78.72%, 1.0100 80.85%, 1.0045 82.98%, 0.9997 85.11%, 0.9959 87.23%, 0.9927 89.36%, 0.9903 91.49%, 0.9884 93.62%, 0.9872 95.74%, 0.9884 97.87%, 1.0000 100.00%)
sampled duration ≈ 667ms 48 points
CSS linear()
JS spring

For an uninterrupted play-through, the baked CSS curve and the live JS spring track each other. Interrupt mid-flight, though, and only the JS spring inherits velocity — the baked curve restarts from a fixed easing.

Playground: a spring in fifteen lines

No imports, no framework — just the integrator animating a DOM box's transform. Tune k, c, m at the top, click anywhere to retarget, and note that velocity is never reset. Break it: set c to 2 and watch it ring; set it to 40 and watch it ooze.