Dynamics · 17
Spring
弹簧 · springDescribe a return with stiffness, damping and mass, carrying position and velocity forward.
A spring has a target, but its initial velocity and damping determine how it arrives. After a drag, it can carry momentum into the return. A per-frame force approximation changes with refresh rate; an analytic step gives the same position at the same elapsed time.
Formula
k = (2π / (1.2·visualDuration))² · mass ζ = 1 − bounce c = 2ζ√(k·mass) m·x″ + c·x′ + k(x − target) = 0
Parameters
| This page | Mature libraries | |
|---|---|---|
| visualDuration | 0.4 s | Motion 0.3 s |
| bounce | 0.2 | Motion 0.3 |
| stiffness / damping | derived | react-spring default 170 / 26 |
Doing it well
- Tune visual duration and bounce first, then inspect stiffness and damping. Motion converts with
(2π / (1.2·vd))².motion packages/motion-dom/src/animation/generators/spring.ts:249–256 - Pass the release velocity into the spring so position and velocity stay continuous.
- Compare react-spring’s default tension 170 and friction 26; libraries name and integrate these values differently, so numbers alone do not port.react-spring packages/core/src/constants.ts
In our cases
7 studies explicitly use springs, including cardwall and emulsion. Some frame-by-frame pulls may look springy, but are recorded as quality issues rather than counted as spring use.
49 / 69 cases · integrate per frame (no dt)From Codex per-case quality flags (keyword-clustered, approximate)
Used in 7 cases
Reference implementation · basics.js
/**
* Damped spring with an exact (analytic) step, so any dt is safe.
* Configure by feel: visualDuration (s) + bounce (0 = no overshoot, 0.5 = lively), like Motion / SwiftUI.
* Or by physics: { stiffness, damping, mass }.
* Rest thresholds are in the value's own units: the defaults suit 0..1 values; for pixels use
* restDelta 0.5 / restSpeed 2 (Motion's defaults), or the tail adds a second of invisible motion.
*/
export class Spring {
constructor({ value = 0, target = value, velocity = 0, visualDuration = 0.4, bounce = 0.2,
stiffness, damping, mass = 1, restDelta = 0.001, restSpeed = 0.01 } = {}) {
Object.assign(this, { value, target, velocity, restDelta, restSpeed, mass });
if (stiffness == null) {
const zeta = bounce >= 0 ? Math.max(1 - bounce, 0.05) : 1 / Math.max(1 + bounce, 0.05);
stiffness = (2 * Math.PI / (1.2 * visualDuration)) ** 2 * mass;
damping = 2 * zeta * Math.sqrt(stiffness * mass);
}
this.stiffness = stiffness; this.damping = damping;
}
get done() { return this.value === this.target && this.velocity === 0; }
step(dt) {
const { stiffness: k, damping: c, mass: m } = this;
const w0 = Math.sqrt(k / m), zeta = c / (2 * Math.sqrt(k * m));
const x0 = this.value - this.target, v0 = this.velocity;
let x, v;
if (zeta < 1) { // underdamped: oscillates and decays
const wd = w0 * Math.sqrt(1 - zeta * zeta), e = Math.exp(-zeta * w0 * dt);
const cos = Math.cos(wd * dt), sin = Math.sin(wd * dt);
x = e * (x0 * cos + ((v0 + zeta * w0 * x0) / wd) * sin);
v = e * (v0 * cos - ((zeta * w0 * v0 + w0 * w0 * x0) / wd) * sin);
} else if (zeta === 1) { // critically damped: fastest without overshoot
const e = Math.exp(-w0 * dt), b = v0 + w0 * x0;
x = e * (x0 + b * dt);
v = e * (v0 - w0 * b * dt);
} else { // overdamped: two decaying exponentials (no cosh overflow)
const s = Math.sqrt(zeta * zeta - 1), r1 = -w0 * (zeta - s), r2 = -w0 * (zeta + s);
const c2 = (v0 - r1 * x0) / (r2 - r1), c1 = x0 - c2;
const e1 = Math.exp(r1 * dt), e2 = Math.exp(r2 * dt);
x = c1 * e1 + c2 * e2;
v = r1 * c1 * e1 + r2 * c2 * e2;
}
if (Math.abs(x) < this.restDelta && Math.abs(v) < this.restSpeed) { x = 0; v = 0; }
this.value = this.target + x; this.velocity = v;
return this.value;
}
}