
Grab Pro Now
Customize your admin
A spring is not an easing curve with a bouncier name — it is the step response of a damped harmonic oscillator, with two parameters instead of four control points. The rig operates those two and derives everything else; the presets are not taste, they were read off a 58-second film frame by frame at 60fps.
Response and damping are the only inputs. Settle time, overshoot and time-to-peak are derived, which is the reason to prefer a spring over a bezier: you tune a feeling and the numbers stay consistent.
The default pop-in — keycaps, chips, toolbar rows, single glyphs.
natural period 2π/ω₀ — smaller is stiffer
underdamped — overshoots and rings
overshoot is a fraction of travel — the same ζ wobbles more the further it goes
Peak at 70ms — 4 frames in at 60fps. The measured film peaks at four.
A spring models an object being moved. That is a strong claim about what is happening on screen, and it is wrong about as often as it is right.
A chip, a menu, a toast, a key landing. The thing did not exist and now does, and a spring is how objects behave when they are put somewhere.
Press-in then settle. The overshoot is the receipt — it is the difference between a button that responded and a button that merely changed colour.
Panels opening, sheets sliding, a list re-sorting. Use ζ ≈ 1: the motion should be physical without any of the edges looking loose.
Anything that behaves like a camera — a zoom, a re-centre, a focus change. Critically damped, always, and usually with a drift underneath it.
Playheads, progress, timers, a countdown. These carry information about time or completion, and easing them makes the readout dishonest.
Spinners, marquees, marching dashes, a slow drift. A spring settles by definition, so it cannot express something that never arrives.
A hover colour, a focus ring, a 2px nudge. Under about 12px of travel the overshoot is sub-pixel — you pay for a spring and receive a transition.
The test that settles most cases: would a physical thing do this? A card sliding onto a desk would. A progress bar filling would not — nothing is being moved, a number is being reported. When the answer is no, the honest curve is usually linear, and the second most honest is a short ease-out.
A spring is the step response of a damped harmonic oscillator — two parameters, three closed forms. The presets in the rig are not taste: they were read off a film frame by frame.
| frame | t (s) | size (px) | centre y |
|---|---|---|---|
| f2 | 30.333 | 64 | 502 |
| f3 | 30.350 | 84 | 431 |
| f4 | 30.367 | 92 | 387 |
| f5 | 30.383 | 96 | 359 |
| f6 | 30.400 | 100 ← peak | 337 |
| f8 | 30.433 | 100 | 307 |
| f12 | 30.500 | 96 | 268 |
| f18 | 30.600 | 92 | 231 |
| f24 | 30.700 | 84 | 208 |
| f28 | 30.767 | 82 | 196 |
| f34 | 30.867 | 82 | 184 |
Time-to-peak is π/ω_d. A peak four frames in (67ms) at ζ≈0.7 puts the response near 0.10s — far stiffer than it looks. A first pass built on a comfortable 0.26s response read as plain CSS transitions beside the source.
// Three regimes, three closed forms. No integration, so it is
// exact, cheap, and identical on the server and the client.
function springStep({ response, damping: z }) {
const w0 = (2 * Math.PI) / response;
if (z < 1) { // underdamped — rings
const wd = w0 * Math.sqrt(1 - z * z);
return (t) => 1 - Math.exp(-z * w0 * t) *
(Math.cos(wd * t) + ((z * w0) / wd) * Math.sin(wd * t));
}
if (z === 1) // critically damped
return (t) => 1 - Math.exp(-w0 * t) * (1 + w0 * t);
const s = Math.sqrt(z * z - 1); // overdamped — crawls
const r1 = -w0 * (z - s), r2 = -w0 * (z + s);
const c1 = r2 / (r2 - r1), c2 = -r1 / (r2 - r1);
return (t) => 1 - (c1 * Math.exp(r1 * t) + c2 * Math.exp(r2 * t));
}
// A spring never arrives; a tween has to. Pin p=1 to exactly 1.
const ease = (p) => (p >= 1 ? 1 : f(p * settleTime));Measured: the element's position rose monotonically across all 34 frames while its scale overshot and settled. That split is what makes a thing read as spring rather than as wobble.
Split on: the box travels without ever passing its target, and only its size springs past and comes back. Nothing about the layout looks unstable.
// The single most useful thing in this studio.
// Position is critically damped; scale is allowed to ring.
tl.to(el, { y: 0, duration: GLIDE_D, ease: GLIDE }, t) // ζ = 1.0
.to(el, { scale: 1, duration: SNAP_D, ease: SNAP }, t); // ζ = 0.7The reference lands nine tiles inside 0.3s — a three-frame gap. Stretch the gap and a group stops being a group.
Inside the measured band. The row arrives as one object that happens to have parts.
// Siblings arrive 2–4 FRAMES apart (33–67ms), never slower.
// The group reads as one gesture; the individuals still land
// separately enough to be counted.
tl.to(items, { scale: 1, duration: SNAP_D, ease: SNAP, stagger: 0.05 }, t);Not one scene in the 58-second source is still. Springs alone do not produce that — they settle, and a settled frame is a dead frame.
The drift is 3% over three seconds — below the threshold of being noticed and above the threshold of the frame looking like a still.
/* Measured: a pill shrank 1088 -> 968px over 0.7s with NO ease
in and NO ease out. A constant-rate dolly, not a settle. */
tl.to(camera, { scale: 1.05, duration: DUR, ease: "none" }, 0);
/* Give it its OWN property so it cannot collide with the spring's
transform on the same element. Two animations writing one
property is how a drift silently cancels a spring. */
.drift { animation: drift 3.2s linear infinite alternate; }
@keyframes drift { to { translate: -14px -3px; scale: 0.965; } }linear() turns the solver into a string. Sample it once, ship it in CSS, and the motion survives with the bundle switched off.
Enough stops that the approximation is invisible. The cost is bytes in the stylesheet, paid once.
/* Sample the solver once at build time, ship the string. */
.chip {
transition: transform 0.271s
linear(0, 0.0142, 0.0855, …, 1.0421, 0.9968, 1);
}
/* 48 stops. Below ~24 a bouncy spring visibly corners at its
peak; above ~64 you are paying bytes for nothing. */The vocabulary is only useful if it has an edge. Four places a spring makes motion worse, each of them a rule the source keeps.
Playheads, timers, countdowns, progress against a known total. A playhead that eases is lying about time — the source runs every one of them at ease: none.
The one 3D move in the reference runs critically damped. A rectangle that rotates past its resting angle and comes back does not read as physics; the eye has no model for it.
Overshoot is a fraction of TRAVEL. At 1080px wide a 5% bounce is 54 real pixels of wobble, so cards, sheets and panels take ζ ≈ 1 and keep their edges believable.
A spring re-targeted before it settles needs its current velocity carried into the new solve. The closed form here starts from rest — if you drag and release, reach for a velocity-aware solver instead.