plans/031-overdamped-spring-exponential-form.md
Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/README.md— unless a reviewer dispatched you and told you they maintain the index.Drift check (run first):
git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/animation/generators/spring.ts packages/motion-dom/src/animation/generators/__tests__/spring.test.tsPlan 030 is expected to have landed first (it adds one entry todurationKeysand edits the visualDuration tests — that drift is fine). Any OTHER drift in the branch structure at lines 282–445: compare against the "Current state" excerpts; on a mismatch, treat it as a STOP condition.
42bfbe3ed, 2026-06-11Open issue #1207 (2021): slow overdamped springs visibly snap to the target mid-animation. Root cause, proven numerically with the reporter's exact config (stiffness: 4, damping: 35, mass: 0.5, range 0→1000): the overdamped branch caps sinh/cosh inputs at 300 to avoid Infinity (Math.min(dampedAngularFreq * t, 300), spring.ts:344). Mathematically, the decaying envelope e^(-ζω₀t) is cancelled by the growing sinh/cosh terms; freezing them at the cap kills that cancellation, so the envelope sweeps the value to the target over ~100ms. Measured: at t=8594ms the generator matches the exact physics (625.49 of 1000); at t=8700ms the generator reads 988.87 while the true spring is at 630.01.
The fix is to rewrite the overdamped solution in exponential form — x(t) = target − (c_slow·e^(λ_slow·t) + c_fast·e^(λ_fast·t)) with both λ < 0 — which is algebraically identical, can never overflow, and removes the cap entirely. While restructuring, unify each branch into a single update(t) that computes position and velocity from one set of Math.exp/sin/cos calls: this extends the existing underdamped hot-path optimization (commit ee9578794) to critically-damped and overdamped springs, deletes the duplicated math between next() and the closures, and shrinks the module (a verified prototype of the dedupe alone measured −224 B min / −49 B gzip on the compiled module; this design is smaller still).
All in packages/motion-dom/src/animation/generators/spring.ts. The region to replace is lines 282–426 (from let resolveSpring through the end of next). Key excerpts as they exist today:
// spring.ts:282-289
let resolveSpring: (v: number) => number
let resolveVelocity: (t: number) => number
// Underdamped coefficients, hoisted for use in the inlined next() hot path
let angularFreq: number
let A: number
let sinCoeff: number
let cosCoeff: number
// spring.ts:335-375 — the overdamped branch with the cap (the #1207 bug)
} else {
// Overdamped spring
const dampedAngularFreq =
undampedAngularFreq * Math.sqrt(dampingRatio * dampingRatio - 1)
resolveSpring = (t: number) => {
const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t)
// When performing sinh or cosh values can hit Infinity so we cap them here
const freqForT = Math.min(dampedAngularFreq * t, 300)
...
// spring.ts:377-426 — generator with duplicated underdamped math inlined in next()
const generator = {
calculatedDuration: isResolvedFromDuration ? duration || null : null,
velocity: (t: number) => secondsToMilliseconds(resolveVelocity(t)),
next: (t: number) => {
if (!isResolvedFromDuration && dampingRatio < 1) {
const envelope = Math.exp(
-dampingRatio * undampedAngularFreq * t
)
const sin = Math.sin(angularFreq * t)
const cos = Math.cos(angularFreq * t)
...
Existing tests that pin behavior (packages/motion-dom/src/animation/generators/__tests__/spring.test.ts):
Overdamped spring (line 77) and Velocity passed to overdamped spring (line 109) — settle before the cap region; must pass unchanged.Overdamped spring with very high stiffness/damping (lines 94–108) — stiffness: 1000000, damping: 10000000 expects [100, 1000] after one 200ms step. This expectation encodes the bug: with ζ = 5000, the cap triggers at t ≈ 0.06ms and snaps the value instantly. The physically correct spring creeps with time constant damping/stiffness = 10s. This test MUST be rewritten (Step 4).toString → returns correct string (line 255) — exact linear(...) strings for underdamped/duration/visualDuration springs; must pass unchanged (none are overdamped).Units convention (keep it): undampedAngularFreq is per-millisecond (millisecondsToSeconds(Math.sqrt(stiffness / mass)), line 263); the velocity closures return px/ms and call sites convert with secondsToMilliseconds(...) before comparing to restSpeed (which is per-second).
Code style: this is a shipped library — prioritize small output bytes; prefer const arrow closures; no default exports.
For ζ > 1, with D = initialDelta, V = initialVelocity + ζω₀D, ω_d = ω₀√(ζ²−1):
The current sinh/cosh solution expands exactly to:
λ_slow = ω_d − ζω₀ — compute it as -ω₀ / (ζ + √(ζ²−1)) (algebraically equal; the subtraction form catastrophically cancels at large ζ, e.g. ζ = 5000)λ_fast = −ω₀(ζ + √(ζ²−1))c_slow = (V + ω_d·D) / (2ω_d)c_fast = D − c_slowx(t) = target − (c_slow·e^(λ_slow·t) + c_fast·e^(λ_fast·t))v(t) = −(c_slow·λ_slow·e^(λ_slow·t) + c_fast·λ_fast·e^(λ_fast·t))Both λ are strictly negative, so e^(λt) ∈ (0, 1] for t ≥ 0 — no overflow, no cap.
| Purpose | Command (from repo root) | Expected on success |
|---|---|---|
| Spring unit tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="generators/__tests__/spring" | all pass |
| Full motion-dom tests | npx jest --config packages/motion-dom/jest.config.json | pass |
| framer-motion client tests | cd packages/framer-motion && yarn test-client | pass (pre-existing use-velocity flake is known — ignore only that) |
| Build all packages | yarn build | exit 0, bundlesize checks pass |
| Module size measurement | npx terser packages/motion-dom/lib/animation/generators/spring.js -c -m | wc -c (after build) | smaller than the pre-change baseline — record both numbers |
In scope (the only files you should modify):
packages/motion-dom/src/animation/generators/spring.ts (lines ~282–426 region only)packages/motion-dom/src/animation/generators/__tests__/spring.test.tsOut of scope (do NOT touch, even though they look related):
findSpring / approximateRoot / getSpringOptions / durationKeys in the same file — plans 030/033 own those.packages/motion-dom/src/animation/generators/utils/calc-duration.ts — the 20s maxGeneratorDuration cap on WAAPI easing generation is a separate, pre-existing limitation (see Maintenance notes).packages/motion-dom/src/animation/JSAnimation.ts.fix/overdamped-spring-snap-1207 off mainFix overdamped springs snapping to target (#1207)Add to spring.test.ts (inside describe("spring", ...)):
test("Overdamped spring does not snap to target mid-animation (#1207)", () => {
const generator = spring({
keyframes: [0, 1000],
stiffness: 4,
damping: 35,
mass: 0.5,
})
// Exact solution at these times: ~625 at 8594ms, ~630 at 8700ms.
// The sinh/cosh cap bug made the value lurch to ~989 by 8700ms.
expect(generator.next(8594).value).toBeCloseTo(625.5, 0)
expect(generator.next(8700).value).toBeCloseTo(630, 0)
expect(generator.next(10000).value).toBeCloseTo(681.2, 0)
})
Verify: run the spring tests → this test FAILS at the 8700ms assertion (current value ≈ 988.87) and the 10000ms assertion (current value = 1000). The 8594ms assertion passes (pre-cap region). If it does not fail this way, STOP.
Add (same describe block):
test("Overdamped spring pre-cap values are preserved", () => {
const generator = spring({
keyframes: [0, 1000],
stiffness: 4,
damping: 35,
mass: 0.5,
})
// Values produced by the current implementation in its correct
// (pre-cap) region — the rewrite must reproduce them.
expect(generator.next(5000).value).toBeCloseTo(434.88, 1)
expect(generator.next(8000).value).toBeCloseTo(599.14, 1)
expect(generator.velocity(5000)).toBeCloseTo(
spring({
keyframes: [0, 1000],
stiffness: 4,
damping: 35,
mass: 0.5,
}).velocity(5000),
5
)
})
(The velocity self-comparison is a placeholder until the rewrite; replace per Step 5.) Before the rewrite, capture the actual velocity number: add a temporary console.log(generator.velocity(5000)), run once, record the value, then hard-code it with toBeCloseTo(<value>, 1) and delete the log. The position values above were verified against the analytic solution during planning.
Verify: spring tests → this test PASSES against current code (it pins correct behavior).
Replace spring.ts lines 282–426 (from let resolveSpring through the closing of next:, keeping toString and toTransition untouched) with the unified structure:
let latest = origin
let latestVelocity = 0
/**
* Each branch computes position and velocity (px/ms) for time t from a
* single set of Math.exp/sin/cos calls — this runs once per frame on the
* animation hot path.
*/
let update: (t: number) => void
if (dampingRatio < 1) {
const angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio)
const A =
(initialVelocity +
dampingRatio * undampedAngularFreq * initialDelta) /
angularFreq
const sinCoeff =
dampingRatio * undampedAngularFreq * A + initialDelta * angularFreq
const cosCoeff =
dampingRatio * undampedAngularFreq * initialDelta - A * angularFreq
// Underdamped spring
update = (t: number) => {
const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t)
const sin = Math.sin(angularFreq * t)
const cos = Math.cos(angularFreq * t)
latest = target - envelope * (A * sin + initialDelta * cos)
latestVelocity = envelope * (sinCoeff * sin + cosCoeff * cos)
}
} else if (dampingRatio === 1) {
// Critically damped spring
const C = initialVelocity + undampedAngularFreq * initialDelta
update = (t: number) => {
const envelope = Math.exp(-undampedAngularFreq * t)
latest = target - envelope * (initialDelta + C * t)
latestVelocity =
envelope * (undampedAngularFreq * C * t - initialVelocity)
}
} else {
// Overdamped branch — final form given in full below
}
The overdamped branch (the bug fix itself):
} else {
// Overdamped spring — exponential form (no sinh/cosh, no overflow cap)
const discriminant = Math.sqrt(dampingRatio * dampingRatio - 1)
const dampedAngularFreq = undampedAngularFreq * discriminant
// Equal to dampedAngularFreq - dampingRatio * undampedAngularFreq,
// computed without catastrophic cancellation at large ratios
const lambdaSlow = -undampedAngularFreq / (dampingRatio + discriminant)
const lambdaFast = -undampedAngularFreq * (dampingRatio + discriminant)
const cSlow =
(initialVelocity +
dampingRatio * undampedAngularFreq * initialDelta +
dampedAngularFreq * initialDelta) /
(2 * dampedAngularFreq)
const cFast = initialDelta - cSlow
update = (t: number) => {
const eSlow = Math.exp(lambdaSlow * t)
const eFast = Math.exp(lambdaFast * t)
latest = target - (cSlow * eSlow + cFast * eFast)
latestVelocity = -(
cSlow * lambdaSlow * eSlow +
cFast * lambdaFast * eFast
)
}
}
Then the generator object becomes:
const generator = {
calculatedDuration: isResolvedFromDuration ? duration || null : null,
velocity: (t: number) => {
update(t)
return secondsToMilliseconds(latestVelocity)
},
next: (t: number) => {
update(t)
if (!isResolvedFromDuration) {
state.done =
Math.abs(secondsToMilliseconds(latestVelocity)) <=
restSpeed! && Math.abs(target - latest) <= restDelta!
} else {
state.done = t >= duration!
}
state.value = state.done ? target : latest
return state
},
toString: ... // unchanged
toTransition: () => {}, // unchanged
}
Delete the now-unused resolveSpring/resolveVelocity declarations, the hoisted angularFreq/A/sinCoeff/cosCoeff lets, and all the old branch bodies. The hoisted-comment block goes too.
Verify: npx tsc -p packages/motion-dom typechecks (or just run the build in Step 6) and the spring test suite → Step 1's test now PASSES, Step 2's pins PASS, Overdamped spring, Velocity passed to overdamped spring, all underdamped/critical/velocity/toString tests PASS. The only acceptable failure at this point is Overdamped spring with very high stiffness/damping — handled next.
The old expectation ([100, 1000] after 200ms for stiffness: 1e6, damping: 1e7) encoded the snap bug — the physically correct spring creeps toward the target over ~70s. Replace the test body (spring.test.ts:94–108) with assertions for what the cap was actually protecting against — overflow/NaN — plus correct creep behavior:
test("Overdamped spring with very high stiffness/damping stays finite and monotonic", () => {
const generator = spring({
keyframes: [100, 1000],
stiffness: 1000000,
damping: 10000000,
restDelta: 1,
restSpeed: 10,
})
let prev = 100
for (let t = 0; t <= 60000; t += 200) {
const value = generator.next(t).value as number
expect(Number.isFinite(value)).toBe(true)
expect(value).toBeGreaterThanOrEqual(prev - 1e-6)
expect(value).toBeLessThanOrEqual(1000)
prev = value
}
// Time constant is damping/stiffness = 10s: ~63% of the way at t=10s
expect(generator.next(10000).value).toBeGreaterThan(600)
})
Note: next() is stateful only in state.done; calling it with increasing t as above matches real usage. Number.isFinite catches both NaN and Infinity.
Verify: full spring suite passes.
Replace the placeholder self-comparison with the recorded numeric value from Step 2 if not already done.
Verify: spring suite passes.
Verify:
npx jest --config packages/motion-dom/jest.config.json → pass.yarn build → exit 0, bundlesize checks pass.cd packages/framer-motion && yarn test-client → pass (springs are exercised broadly here; any failure in animation tests means behavioral drift — STOP and compare values).npx terser packages/motion-dom/lib/animation/generators/spring.js -c -m | wc -c → record; baseline at 42bfbe3ed was 4407 bytes min (1681 gzip). Expect a decrease (prototype of a weaker version of this design measured 4183/1632). Report both numbers in your completion summary. A small increase is a STOP condition (the design should only shrink the module).#1207 regression test (Step 1) — fails on the cap snap, passes with exponential form.Velocity passed to * tests (they compare velocity-vs-no-velocity curves and would catch coefficient sign errors), toString exact-string test (catches any underdamped numeric drift), visualDuration tests.Machine-checkable. ALL must hold:
cd packages/framer-motion && yarn test-client exits 0yarn build exits 0 (bundlesize gates green)grep -n "sinh\|cosh\|Math.min(dampedAngularFreq" packages/motion-dom/src/animation/generators/spring.ts returns no matchesgit status)plans/README.md status row updatedStop and report back (do not improvise) if:
toString exact-string test fails — those paths must be bit-identical; a failure means the restructure changed shared code it shouldn't have.toBeCloseTo tolerance — the exponential coefficients are wrong; do not loosen tolerances to pass.JSAnimation.ts, calc-duration.ts, or the findSpring region.linear() easing path (maxGeneratorDuration in calc-duration.ts, and toString()); and slower than 10s in pregenerateKeyframes. After this fix, very slow overdamped springs animate correctly on the JS path but will still hard-stop at 20s if WAAPI-accelerated. If #1207-style configs remain visibly truncated in browsers, that cap is the next thing to look at — file it separately.lambdaSlow form (the naive dampedAngularFreq - dampingRatio * undampedAngularFreq is exactly the cancellation trap), and that velocity() units stayed px/ms → converted by secondsToMilliseconds at both call sites.update(t) contract (sets latest/latestVelocity module-locals) is single-threaded per-generator state; if a future feature needs random-access reads of position AND velocity at different times in one tick, revisit.