Spring To CSS linear()
A spring is a differential equation, and the usual way to run one on the web is a JavaScript loop that integrates it every frame — on the main thread, where a long task or a garbage collection makes it visibly hitch. `linear()` takes a list of output values and interpolates between them, and those values are not clamped to [0, 1], so a dense enough sample of a spring *is* the spring, overshoot included. Solve it once at author time, emit the samples, and the animation runs on the compositor where nothing on the main thread can reach it.
Spring To CSS linear()
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/spring-to-linear-easing.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
4 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| stiffness | number | = 100 | Restoring force per unit displacement. Higher is faster and tighter. |
| damping | number | = 10 | Resistance proportional to velocity. Ignored when bounce is given. |
| mass | number | = 1 | Inertia. Raising it lowers the natural frequency and lengthens the settle. |
| bounce | number | — | Overshoot 0-1, relative to critical damping. 0 is monotonic, 0.5 is lively. Overrides damping. |
| velocity | number | = 0 | Initial velocity in fractions of the distance per second, for handing off from a gesture. |
| restDelta | number | = 0.005 | Remaining distance, as a fraction, that counts as arrived. |
| restSpeed | number | = 0.1 | Speed, in fractions per second, that counts as stopped. |
| resolutionMs | number | = 10 | Milliseconds between emitted stops. |
| maxDurationMs | number | = 10000 | Ceiling on the solved duration. |
| precision | number | = 4 | Decimal places per sample. |
How it works
The technique in order, not the code in order.
The motion is the second-order system
d'' + 2*zeta*w0*d' + w0^2*d = 0on the *remaining* distanced, withd(0) = 1andd'(0) = -velocity. The natural frequency isw0 = sqrt(stiffness / mass)and the damping ratio iszeta = damping / (2 * sqrt(stiffness * mass)).Each damping regime has its own closed form. Underdamped (
zeta < 1) isexp(-zeta*w0*t) * (cos(wd*t) + b*sin(wd*t))withwd = w0*sqrt(1 - zeta^2)andb = (zeta*w0 - v0) / wd. Critically damped (zeta = 1) collapses toexp(-w0*t) * (1 + (w0 - v0)*t). Overdamped (zeta > 1) is a sum of two real exponentials with roots-zeta*w0 ± w0*sqrt(zeta^2 - 1). Picking the wrong branch is silent untilsqrt(1 - zeta^2)goes imaginary and every sample is NaN.bounceis expressed against the critical damping coefficient2*sqrt(stiffness*mass)rather than as an absolute damping value, which is what makesbounce: 0mean exactly "no overshoot" at any stiffness. Without that normalisation the same damping number is bouncy at high stiffness and sluggish at low.Duration is not a parameter — it is solved. A 240 Hz scan walks the solution until the spring is both close enough (
|d| < restDelta) and slow enough (|d'| < restSpeed), and the firsttthat satisfies both becomes the duration. Both tests are needed: an underdamped spring crosses the target at full speed on every oscillation, so a distance-only test declares it settled half a bounce in.The solved duration is divided by
resolutionMsto get a stop count, capped at 200, and the solution is evaluated at each stop. Samples are1 - d(t)— progress, not remaining distance — and are deliberately left unclamped, because values above 1 are the overshoot and clamping them is precisely how a spring degrades into an ease-out.The last sample is forced to exactly 1. The scan stopped within
restDeltaof the target, so it would otherwise be ~0.999 and every transition would end with a visible sub-pixel snap as the browser jumps from the final easing output to the real end value.fallbackis a monotoniccubic-bezier()meant to be declared on the line *before* thelinear()one. An engine that cannot parselinear()drops that declaration during parsing and the earlier one survives — the ordinary CSS cascade, with no feature query.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| MAX_STOPS | = 200 | Upper bound on samples. The compositor shows one value per frame, so stops closer together than a frame are bytes with no visible effect — 200 stops across a 1.6s spring is one every 8 ms, already finer than a 120 Hz frame. It also bounds the emitted string: a slow overdamped spring at 10 ms resolution would otherwise run past a thousand stops. |
| resolutionMs | = 10 | Milliseconds between samples. Below ~6 ms the string grows with no visual change even at 165 Hz; past ~20 ms the linear segments between stops become long enough that the interpolation reads as faceted rather than smooth, most visibly around the first peak where curvature is highest. |
| SCAN_STEP | = 1 / 240 | Integration step for the settle scan — four samples per 60 Hz frame. Coarser steps can step straight over the narrow window where an underdamped spring is simultaneously near the target and slow, which sends the duration to maxDurationMs. |
| MIN_RATIO | = 0.05 | Floor on the damping ratio. At zeta = 0 the spring is undamped and never satisfies the rest test, so the scan runs to maxDurationMs and emits a 200-stop string of a curve that oscillates forever. bounce is clamped to 1 - MIN_RATIO for the same reason. |
| restDelta | = 0.005 | Distance, as a fraction of the total, that counts as arrived. At 0.005 a 400 px move stops 2 px out, under the forced final sample, so the snap is invisible. An order of magnitude tighter roughly doubles the duration of a bouncy spring for motion nobody can see. |
| restSpeed | = 0.1 | Speed, in fractions per second, that counts as stopped. Paired with restDelta so a fast pass through the target is not mistaken for arrival. |
| precision | = 4 | Decimal places per sample. Four is sub-pixel on any realistic distance (0.0001 of 2000 px is 0.2 px) and keeps a 200-stop string near 1.4 KB. Full float precision triples the byte count for nothing. |
| maxDurationMs | = 10000 | Hard ceiling so a pathological configuration returns a long easing rather than hanging the scan loop. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
useSpringEasing recomputes with bounce: 0 and velocity: 0, forcing the damping ratio to exactly 1. That removes the oscillation — the trigger for vestibular discomfort — while keeping the stiffness and therefore the pace, so the motion stays the same length and simply arrives once instead of settling. It is not shortened to zero: the curve is still a real ease, just monotonic, and the demo's peak readout drops to 100% to show it.
Nothing to clean up: springToLinear is a pure function and useSpringEasing only calls it inside useMemo. No timers, listeners, or animation frames are created. The reduced-motion subscription belongs to useReducedMotion, which removes its own media-query listener on unmount.
Press Run. The three squares cross their tracks and each one visibly passes its end position and settles back — Loose the most, Snap barely. The plotted curve beside each row rises above the dashed 100% line by the amount stated in the readout. Scroll or drag hard during the run and the motion does not hitch, because it is not on the main thread. Read the emitted string: it starts at 0, contains values greater than 1, and ends at exactly 1. Turn reduced motion on and re-run: every curve is monotonic, no square passes its end position, every peak reads 100%, and the durations are still hundreds of milliseconds rather than zero.
Pitfalls
- Clamping the samples to [0, 1]. This is the one mistake that destroys the whole technique: the overshoot lives in the values above 1, and a clamped spring is an ease-out with extra bytes. It looks like the maths is wrong when the maths is fine.
- Reading the raw solution as progress. The closed form gives *remaining distance*, which starts at 1 and decays to 0; the easing needs
1 - d. Emittingddirectly produces an animation that runs backwards and every reviewer misdiagnoses it as an inverted easing. - Using one formula for all damping ratios.
sqrt(1 - zeta^2)is imaginary above zeta = 1, so an overdamped spring evaluated with the underdamped branch yields NaN, andlinear(0, NaN, NaN)fails to parse — the transition silently reverts toeasewith nothing in the console. - Testing only distance for the settle time. An underdamped spring passes exactly through the target on every swing, so the scan stops on the first crossing and the emitted curve is cut off mid-bounce.
- Leaving the final sample at its solved value. It is ~0.999, and the browser jumps from there to the real end value in one frame — a visible tick at the end of every transition that reads as a rendering bug.
- Passing the
linear()string through a CSS custom property. An unsupported value insidevar()is invalid at computed-value time, which resolves the property to its *initial* value (ease) instead of falling back to the previous declaration. That silently defeats the two-declaration fallback in exactly the browsers that need it. - Declaring the fallback after
linear(). Later declarations win, so a supporting browser also uses the cubic-bezier and the spring never appears anywhere. - Setting a duration by hand. Duration is an output of the spring, not an input; overriding it rescales the curve in time so a stiff spring animates slowly with a stiff shape, which looks wrong without looking obviously wrong.
- Recomputing the easing every render. The scan runs up to 2400 solver evaluations, so an unmemoised call inside a frequently-rendering component is measurable main-thread work to produce a string that never changes.
- Using this for anything interruptible. A
linear()easing is a fixed curve of fixed length; a spring that must absorb a new target mid-flight, with its current velocity carried over, still needs a real per-frame integrator.
Notes
- Deterministic and pure, so it is safe to precompute at build time and inline the string into a stylesheet — the runtime cost then drops to zero.
springToLinearis exported separately from the hook for that reason. - Works for any animatable property, including ones a JavaScript spring handles badly:
linear()on abackground-colortransition springs the colour without touching the main thread at all. - A
linear()easing applies to Web Animations and CSS animations identically. Passingeasingas theeasingoption ofelement.animate()gets the same compositor handoff. - The overshoot means the property leaves the 0-1 range, so a spring on
scalemomentarily exceeds the target scale. Anything clipped by anoverflow: hiddenparent should be sized with that headroom in mind — the demo's peak readout is the exact multiplier.