From e931020892f1cd2a2e4611d1879060b1904da5ae Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Mar 2026 13:47:05 +0100 Subject: [PATCH 1/7] Fix laggy spring animations at high refresh rates (240hz) Spring animations using useSpring/attachFollow were systematically losing velocity at high frame rates because the animation was stopped and restarted every frame with velocity estimated via finite difference. At 240hz (~4ms frames), the finite difference severely underestimated the spring's true velocity, causing the spring to fall ~22% behind vs 60hz. Fix: Use the spring generator's analytical velocity derivative instead of the MotionValue's frame-dependent finite difference. Also use a stable function reference for frame.postRender to deduplicate redundant callbacks from rapid input events within a single frame. Fixes #3265 Co-Authored-By: Claude Opus 4.6 --- .../motion-dom/src/animation/JSAnimation.ts | 22 +++++ .../src/animation/generators/spring/index.ts | 51 +++++++++-- packages/motion-dom/src/animation/types.ts | 1 + .../__tests__/follow-value-framerate.test.ts | 88 +++++++++++++++++++ packages/motion-dom/src/value/follow-value.ts | 33 ++++--- 5 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index d1f5eef5f9..f9f0311d8c 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -4,6 +4,7 @@ import { millisecondsToSeconds, pipe, secondsToMilliseconds, + velocityPerSecond, } from "motion-utils" import { time } from "../frameloop/sync-time" import { activeAnimations } from "../stats/animation-count" @@ -384,6 +385,27 @@ export class JSAnimation } } + /** + * Returns the generator's velocity at the current time in units/second. + * Uses the analytical derivative when available (springs), avoiding + * the MotionValue's frame-dependent velocity estimation. + */ + get generatorVelocity(): number { + const t = this.currentTime + if (t <= 0) return this.options.velocity || 0 + + if (this.generator.velocity) { + return this.generator.velocity(t) + } + + // Fallback: finite difference via calcGeneratorVelocity + const sampleDuration = 5 + const prevT = Math.max(t - sampleDuration, 0) + const prev = this.generator.next(prevT).value as number + const current = this.generator.next(t).value as number + return velocityPerSecond(current - prev, t - prevT) + } + get speed() { return this.playbackSpeed } diff --git a/packages/motion-dom/src/animation/generators/spring/index.ts b/packages/motion-dom/src/animation/generators/spring/index.ts index faa17eefaf..c3e5745ac8 100644 --- a/packages/motion-dom/src/animation/generators/spring/index.ts +++ b/packages/motion-dom/src/animation/generators/spring/index.ts @@ -139,9 +139,16 @@ function spring( : springDefaults.restDelta.default let resolveSpring: (v: number) => number + let resolveVelocity: (t: number) => number if (dampingRatio < 1) { const angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio) + const A = + (initialVelocity + + dampingRatio * undampedAngularFreq * initialDelta) / + angularFreq + const B = initialDelta + // Underdamped spring resolveSpring = (t: number) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t) @@ -149,13 +156,22 @@ function spring( return ( target - envelope * - (((initialVelocity + - dampingRatio * undampedAngularFreq * initialDelta) / - angularFreq) * - Math.sin(angularFreq * t) + - initialDelta * Math.cos(angularFreq * t)) + (A * Math.sin(angularFreq * t) + + B * Math.cos(angularFreq * t)) ) } + + // Analytical derivative of underdamped spring (px/ms) + const sinCoeff = + dampingRatio * undampedAngularFreq * A + B * angularFreq + const cosCoeff = + dampingRatio * undampedAngularFreq * B - A * angularFreq + resolveVelocity = (t: number) => { + const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t) + return envelope * + (sinCoeff * Math.sin(angularFreq * t) + + cosCoeff * Math.cos(angularFreq * t)) + } } else if (dampingRatio === 1) { // Critically damped spring resolveSpring = (t: number) => @@ -163,6 +179,12 @@ function spring( Math.exp(-undampedAngularFreq * t) * (initialDelta + (initialVelocity + undampedAngularFreq * initialDelta) * t) + + // Analytical derivative of critically damped spring (px/ms) + const C = initialVelocity + undampedAngularFreq * initialDelta + resolveVelocity = (t: number) => + Math.exp(-undampedAngularFreq * t) * + (undampedAngularFreq * C * t - initialVelocity) } else { // Overdamped spring const dampedAngularFreq = @@ -186,10 +208,29 @@ function spring( dampedAngularFreq ) } + + // Analytical derivative of overdamped spring (px/ms) + const P = + (initialVelocity + + dampingRatio * undampedAngularFreq * initialDelta) / + dampedAngularFreq + const Q = initialDelta + const sinhCoeff = + dampingRatio * undampedAngularFreq * P - Q * dampedAngularFreq + const coshCoeff = + dampingRatio * undampedAngularFreq * Q - P * dampedAngularFreq + resolveVelocity = (t: number) => { + const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t) + const freqForT = Math.min(dampedAngularFreq * t, 300) + return envelope * + (sinhCoeff * Math.sinh(freqForT) + + coshCoeff * Math.cosh(freqForT)) + } } const generator = { calculatedDuration: isResolvedFromDuration ? duration || null : null, + velocity: (t: number) => secondsToMilliseconds(resolveVelocity(t)), next: (t: number) => { const current = resolveSpring(t) diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index 07585acd3d..801584dade 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -116,6 +116,7 @@ export interface AnimationState { export interface KeyframeGenerator { calculatedDuration: null | number next: (t: number) => AnimationState + velocity?: (t: number) => number toString: () => string } diff --git a/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts new file mode 100644 index 0000000000..044f08ba85 --- /dev/null +++ b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts @@ -0,0 +1,88 @@ +import { MotionGlobalConfig } from "motion-utils" +import { motionValue } from "../index" +import { attachSpring } from "../spring-value" +import { frameSteps, frameData } from "../../frameloop" +import { time } from "../../frameloop/sync-time" + +/** + * Process a single frame at the given timestamp, running all frame loop steps. + */ +function processFrame(timestamp: number) { + frameData.timestamp = timestamp + frameData.delta = timestamp - (frameData.timestamp || 0) || 1000 / 60 + frameData.isProcessing = true + time.set(timestamp) + frameSteps.setup.process(frameData) + frameSteps.read.process(frameData) + frameSteps.resolveKeyframes.process(frameData) + frameSteps.preUpdate.process(frameData) + frameSteps.update.process(frameData) + frameSteps.preRender.process(frameData) + frameSteps.render.process(frameData) + frameSteps.postRender.process(frameData) + frameData.isProcessing = false +} + +describe("Spring follow at different frame rates (issue #3265)", () => { + beforeEach(() => { + MotionGlobalConfig.useManualTiming = true + frameData.timestamp = 0 + time.set(0) + }) + + afterEach(() => { + MotionGlobalConfig.useManualTiming = false + }) + + test("spring position should be consistent at 240hz vs 60hz when following a moving target", () => { + /** + * The bug: at 240hz, the spring animation restarts every ~4ms with + * an inaccurate velocity estimate (finite difference), causing the + * spring to systematically lose energy and fall behind compared to + * 60hz where restarts happen every ~16ms. + */ + const stiffness = 100 + const damping = 10 + const mass = 1 + const springOpts = { stiffness, damping, mass } + + // Test with a linearly moving target over 100ms + const totalTime = 100 + const targetVelocity = 500 // px/s => target moves 50px in 100ms + + function simulateSpring(fps: number): number { + const source = motionValue(0) + const output = motionValue(0) + const cleanup = attachSpring(output, source, springOpts) + + const interval = 1000 / fps + let t = 0 + + // Initial frame to set up the spring + source.set(0) + processFrame(t) + + const numFrames = Math.ceil(totalTime / interval) + for (let i = 1; i <= numFrames; i++) { + t = i * interval + // Move the target (like mouse movement) + source.set(targetVelocity * (t / 1000)) + processFrame(t) + } + + const result = output.get() + cleanup() + return result + } + + const pos60 = simulateSpring(60) + const pos240 = simulateSpring(240) + + // Both frame rates should produce similar spring positions. + // Before the fix, 240hz was ~34% behind 60hz. + // After the fix, they should be within 15% of each other. + const ratio = pos240 / pos60 + expect(ratio).toBeGreaterThan(0.85) + expect(ratio).toBeLessThan(1.15) + }) +}) diff --git a/packages/motion-dom/src/value/follow-value.ts b/packages/motion-dom/src/value/follow-value.ts index 5c4759ce87..0a61787952 100644 --- a/packages/motion-dom/src/value/follow-value.ts +++ b/packages/motion-dom/src/value/follow-value.ts @@ -77,19 +77,27 @@ export function attachFollow( } const startAnimation = () => { - stopAnimation() - const currentValue = asNumber(value.get()) const targetValue = asNumber(latestValue) // Don't animate if we're already at the target if (currentValue === targetValue) { + stopAnimation() return } + // Use the running animation's analytical velocity for accuracy, + // falling back to the MotionValue's velocity for the initial animation. + // This prevents systematic velocity loss at high frame rates (240hz+). + const velocity = activeAnimation + ? activeAnimation.generatorVelocity + : value.getVelocity() + + stopAnimation() + activeAnimation = new JSAnimation({ keyframes: [currentValue, targetValue], - velocity: value.getVelocity(), + velocity, // Default to spring if no type specified (matches useSpring behavior) type: "spring", restDelta: 0.001, @@ -99,17 +107,20 @@ export function attachFollow( }) } + // Use a stable function reference so the frame loop Set deduplicates + // multiple calls within the same frame (e.g. rapid mouse events) + const scheduleAnimation = () => { + startAnimation() + value["events"].animationStart?.notify() + activeAnimation?.then(() => { + value["events"].animationComplete?.notify() + }) + } + value.attach((v, set) => { latestValue = v latestSetter = (latest) => set(parseValue(latest, unit) as T) - - frame.postRender(() => { - startAnimation() - value["events"].animationStart?.notify() - activeAnimation?.then(() => { - value["events"].animationComplete?.notify() - }) - }) + frame.postRender(scheduleAnimation) }, stopAnimation) if (isMotionValue(source)) { From 335541f1fa301135404f43d1811072661f2578c6 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Mar 2026 21:55:01 +0100 Subject: [PATCH 2/7] Simplify: reuse calcGeneratorVelocity, fix test delta bug - Replace inline finite-difference fallback in generatorVelocity with existing calcGeneratorVelocity utility, removing duplicate logic - Fix processFrame test helper: save previous timestamp before overwriting so delta is computed correctly Co-Authored-By: Claude Opus 4.6 --- packages/motion-dom/src/animation/JSAnimation.ts | 13 +++++++------ .../value/__tests__/follow-value-framerate.test.ts | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index f9f0311d8c..5e64c0d141 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -4,7 +4,6 @@ import { millisecondsToSeconds, pipe, secondsToMilliseconds, - velocityPerSecond, } from "motion-utils" import { time } from "../frameloop/sync-time" import { activeAnimations } from "../stats/animation-count" @@ -15,6 +14,7 @@ import { DriverControls } from "./drivers/types" import { inertia } from "./generators/inertia" import { keyframes as keyframesGenerator } from "./generators/keyframes" import { calcGeneratorDuration } from "./generators/utils/calc-duration" +import { calcGeneratorVelocity } from "./generators/utils/velocity" import { getFinalKeyframe } from "./keyframes/get-final" import { AnimationPlaybackControlsWithThen, @@ -398,12 +398,13 @@ export class JSAnimation return this.generator.velocity(t) } - // Fallback: finite difference via calcGeneratorVelocity - const sampleDuration = 5 - const prevT = Math.max(t - sampleDuration, 0) - const prev = this.generator.next(prevT).value as number + // Fallback: finite difference const current = this.generator.next(t).value as number - return velocityPerSecond(current - prev, t - prevT) + return calcGeneratorVelocity( + (s) => this.generator.next(s).value as number, + t, + current + ) } get speed() { diff --git a/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts index 044f08ba85..f7a5837228 100644 --- a/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts +++ b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts @@ -8,8 +8,9 @@ import { time } from "../../frameloop/sync-time" * Process a single frame at the given timestamp, running all frame loop steps. */ function processFrame(timestamp: number) { + const prevTimestamp = frameData.timestamp frameData.timestamp = timestamp - frameData.delta = timestamp - (frameData.timestamp || 0) || 1000 / 60 + frameData.delta = timestamp - (prevTimestamp || 0) || 1000 / 60 frameData.isProcessing = true time.set(timestamp) frameSteps.setup.process(frameData) From 1b50e99d0bc0d05b6f25ebc0f44e552ea56eb767 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Mar 2026 09:48:49 +0100 Subject: [PATCH 3/7] Remove redundant B = initialDelta alias in spring generator Co-Authored-By: Claude Opus 4.6 --- .../motion-dom/src/animation/generators/spring/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/motion-dom/src/animation/generators/spring/index.ts b/packages/motion-dom/src/animation/generators/spring/index.ts index c3e5745ac8..b7f322c98c 100644 --- a/packages/motion-dom/src/animation/generators/spring/index.ts +++ b/packages/motion-dom/src/animation/generators/spring/index.ts @@ -147,7 +147,6 @@ function spring( (initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / angularFreq - const B = initialDelta // Underdamped spring resolveSpring = (t: number) => { @@ -157,15 +156,15 @@ function spring( target - envelope * (A * Math.sin(angularFreq * t) + - B * Math.cos(angularFreq * t)) + initialDelta * Math.cos(angularFreq * t)) ) } // Analytical derivative of underdamped spring (px/ms) const sinCoeff = - dampingRatio * undampedAngularFreq * A + B * angularFreq + dampingRatio * undampedAngularFreq * A + initialDelta * angularFreq const cosCoeff = - dampingRatio * undampedAngularFreq * B - A * angularFreq + dampingRatio * undampedAngularFreq * initialDelta - A * angularFreq resolveVelocity = (t: number) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t) return envelope * From a5bb1861e7084e517d99f297ff357a27e922fad5 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Mar 2026 15:45:24 +0100 Subject: [PATCH 4/7] Consolidate spring generator into single file, use analytical velocity for rest detection Merge spring/index.ts, spring/find.ts, spring/defaults.ts, and spring/utils.ts into a single generators/spring.ts file. Replace numerical velocity approximation (calcGeneratorVelocity) with analytical resolveVelocity in the spring's next() method for more accurate rest detection. Update test snapshots for the slightly earlier rest detection. Co-Authored-By: Claude Opus 4.6 --- .../sequence/__tests__/index.test.ts | 2 +- .../src/motion/__tests__/waapi.test.tsx | 4 +- .../generators/__tests__/inertia.test.ts | 35 +++- .../generators/__tests__/spring.test.ts | 2 +- .../generators/{spring/index.ts => spring.ts} | 166 ++++++++++++++++-- .../animation/generators/spring/defaults.ts | 28 --- .../src/animation/generators/spring/find.ts | 125 ------------- .../src/animation/generators/spring/utils.ts | 1 - 8 files changed, 187 insertions(+), 176 deletions(-) rename packages/motion-dom/src/animation/generators/{spring/index.ts => spring.ts} (67%) delete mode 100644 packages/motion-dom/src/animation/generators/spring/defaults.ts delete mode 100644 packages/motion-dom/src/animation/generators/spring/find.ts delete mode 100644 packages/motion-dom/src/animation/generators/spring/utils.ts diff --git a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts index 7a2aea21e3..e042c12fe2 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -587,7 +587,7 @@ describe("createAnimationsFromSequence", () => { expect(animations.get(a)!.keyframes.x).toEqual([0, 100]) const { duration, ease } = animations.get(a)!.transition.x - expect(duration).toEqual(1.1) + expect(duration).toEqual(1.05) expect(typeof (ease as Easing[])[0]).toEqual("function") }) diff --git a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx index 9d0e41c36f..25d08e6312 100644 --- a/packages/framer-motion/src/motion/__tests__/waapi.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/waapi.test.tsx @@ -126,8 +126,8 @@ describe("WAAPI animations", () => { }, { delay: -0, - duration: 1100, - easing: "linear(0, 0.0049, 0.019, 0.0412, 0.0706, 0.1061, 0.1469, 0.1921, 0.2407, 0.292, 0.3452, 0.3997, 0.4547, 0.5097, 0.5642, 0.6177, 0.6698, 0.72, 0.7681, 0.8139, 0.8571, 0.8975, 0.935, 0.9696, 1.0011, 1.0296, 1.055, 1.0775, 1.0971, 1.1139, 1.1279, 1.1394, 1.1484, 1.1551, 1.1597, 1.1623, 1.163, 1.1621, 1.1598, 1.1561, 1.1512, 1.1454, 1.1387, 1.1313, 1.1234, 1.115, 1.1063, 1.0974, 1.0884, 1.0794, 1.0706, 1.0619, 1.0534, 1.0452, 1.0374, 1.03, 1.0229, 1.0164, 1.0103, 1.0047, 0.9996, 0.9949, 0.9908, 0.9872, 0.984, 0.9813, 0.979, 0.9772, 0.9757, 0.9747, 0.9739, 0.9735, 0.9734, 0.9736, 0.974, 0.9746, 0.9754, 0.9764, 0.9774, 0.9787, 0.98, 0.9813, 0.9827, 0.9842, 0.9857, 0.9871, 0.9886, 0.99, 0.9914, 0.9927, 0.994, 0.9952, 0.9963, 0.9974, 0.9984, 0.9993, 1.0001, 1.0009, 1.0015, 1.0021, 1.0026, 1.0031, 1.0034, 1.0037, 1.004, 1, 1, 1, 1, 1)", + duration: 1050, + easing: "linear(0, 0.0049, 0.019, 0.0413, 0.0707, 0.1062, 0.147, 0.1922, 0.2408, 0.2922, 0.3454, 0.3999, 0.455, 0.51, 0.5646, 0.6181, 0.6701, 0.7204, 0.7685, 0.8143, 0.8574, 0.8978, 0.9353, 0.9699, 1.0014, 1.0299, 1.0553, 1.0778, 1.0973, 1.1141, 1.1281, 1.1395, 1.1485, 1.1552, 1.1597, 1.1623, 1.163, 1.1621, 1.1597, 1.156, 1.1511, 1.1453, 1.1386, 1.1312, 1.1232, 1.1148, 1.1061, 1.0972, 1.0882, 1.0793, 1.0704, 1.0617, 1.0532, 1.045, 1.0372, 1.0298, 1.0228, 1.0162, 1.0101, 1.0045, 0.9994, 0.9948, 0.9907, 0.9871, 0.9839, 0.9812, 0.979, 0.9771, 0.9757, 0.9746, 0.9739, 0.9735, 0.9734, 0.9736, 0.974, 0.9746, 0.9754, 0.9764, 0.9775, 0.9787, 0.98, 0.9814, 0.9828, 0.9843, 0.9857, 0.9872, 0.9886, 0.99, 0.9914, 0.9927, 0.994, 0.9952, 0.9964, 0.9974, 0.9984, 0.9993, 1.0001, 1.0009, 1.0016, 1.0021, 1.0027, 1.0031, 1.0035, 1.0037, 1)", iterations: 1, direction: "normal", fill: "both", diff --git a/packages/motion-dom/src/animation/generators/__tests__/inertia.test.ts b/packages/motion-dom/src/animation/generators/__tests__/inertia.test.ts index 490f8fff78..774b8f0e99 100644 --- a/packages/motion-dom/src/animation/generators/__tests__/inertia.test.ts +++ b/packages/motion-dom/src/animation/generators/__tests__/inertia.test.ts @@ -97,7 +97,7 @@ describe("inertia", () => { min: 0, }) const { keyframes, duration } = pregenerateKeyframes(generator) - expect(duration).toEqual(1.42) + expect(duration).toEqual(1.85) expect(keyframes).toEqual(expectedAnimationKeyframesWithMin) }) @@ -125,7 +125,7 @@ describe("inertia", () => { max: 200, }) const { keyframes, duration } = pregenerateKeyframes(generator) - expect(duration).toEqual(1.42) + expect(duration).toEqual(1.85) expect(keyframes).toEqual(expectedAnimationKeyframesWithMax) }) @@ -395,7 +395,21 @@ const expectedAnimationKeyframesWithMin = [ -0.06449211350032416, -0.05128936389831648, -0.03691275765931889, -0.0221552144769921, -0.007752247846719602, 0.005647423141537939, 0.017504352738798582, 0.027403530576788012, 0.03506220467756449, - 0.040330693153308554, 0.04318681334953267, 0, + 0.040330693153308554, 0.04318681334953267, 0.0437247997784003, + 0.04213976040910249, 0.03870883241054338, 0.033770244339933406, + 0.027701476109967472, 0.02089763744729107, 0.013751068552181367, + 0.006633013241694939, -0.00012196425782889296, -0.0062283415763344264, + -0.011458500270676337, -0.01564800087287013, -0.018697362316561276, + -0.020570594078835346, -0.02129085960947034, -0.020933749523185694, + -0.019618710755027215, -0.017499213277943633, -0.014752240453738455, + -0.011567665365081987, -0.00813802736441628, -0.004649155175939006, + -0.0012720002997873648, 0.0018440474959955946, 0.0045761869003028, + 0.006831493576234213, 0.008548480061993557, 0.009697017641601163, + 0.010276779766636322, 0.010314421477998086, 0.009859748761770204, + 0.008981155468606688, 0.007760613711452574, 0.006288497605736688, + 0.004658501480378907, 0.0029628843619596023, 0.0012882350155569177, + -0.0002880913285060006, -0.0017007596413722969, -0.002898407017964739, + -0.0038447468026205616, -0.00451885327934322, -0.0049146914166663174, 0, ] const expectedAnimationKeyframesNegative = [ @@ -543,5 +557,18 @@ const expectedAnimationKeyframesWithMax = [ 200.05128936389832, 200.0369127576593, 200.02215521447698, 200.00775224784672, 199.99435257685846, 199.9824956472612, 199.97259646942322, 199.96493779532244, 199.95966930684668, - 199.95681318665046, 200, + 199.95681318665046, 199.9562752002216, 199.9578602395909, + 199.96129116758945, 199.96622975566007, 199.97229852389003, + 199.9791023625527, 199.98624893144782, 199.9933669867583, + 200.00012196425783, 200.00622834157633, 200.01145850027066, + 200.01564800087286, 200.01869736231657, 200.02057059407883, + 200.02129085960948, 200.02093374952318, 200.01961871075503, + 200.01749921327794, 200.01475224045373, 200.01156766536508, + 200.00813802736442, 200.00464915517594, 200.0012720002998, + 199.998155952504, 199.9954238130997, 199.99316850642376, 199.991451519938, + 199.9903029823584, 199.98972322023337, 199.989685578522, 199.99014025123822, + 199.9910188445314, 199.99223938628856, 199.99371150239426, + 199.99534149851962, 199.99703711563805, 199.99871176498445, + 200.0002880913285, 200.00170075964138, 200.00289840701797, + 200.00384474680263, 200.00451885327934, 200.00491469141667, 200, ] diff --git a/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts b/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts index 35eb0c0b34..f9550b0d7f 100644 --- a/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts +++ b/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts @@ -233,7 +233,7 @@ describe("toString", () => { }) expect(physicsSpring.toString()).toBe( - "1100ms linear(0, 0.0419, 0.1493, 0.2963, 0.4608, 0.625, 0.7759, 0.905, 1.0077, 1.0827, 1.1314, 1.1567, 1.1629, 1.1545, 1.1359, 1.1114, 1.0844, 1.0578, 1.0336, 1.0131, 0.9969, 0.9853, 0.9779, 0.9742, 0.9735, 0.9751, 0.9783, 0.9824, 0.9868, 0.9911, 0.995, 0.9982, 1.0008, 1.0026, 1.0037, 1.0043, 1)" + "1100ms linear(0, 0.0419, 0.1493, 0.2963, 0.4608, 0.625, 0.7759, 0.905, 1.0077, 1.0827, 1.1314, 1.1567, 1.1629, 1.1545, 1.1359, 1.1114, 1.0844, 1.0578, 1.0336, 1.0131, 0.9969, 0.9853, 0.9779, 0.9742, 0.9735, 0.9751, 0.9783, 0.9824, 0.9868, 0.9911, 0.995, 0.9982, 1.0008, 1.0026, 1.0037, 1, 1)" ) const durationSpring = spring({ diff --git a/packages/motion-dom/src/animation/generators/spring/index.ts b/packages/motion-dom/src/animation/generators/spring.ts similarity index 67% rename from packages/motion-dom/src/animation/generators/spring/index.ts rename to packages/motion-dom/src/animation/generators/spring.ts index b7f322c98c..d3701c3614 100644 --- a/packages/motion-dom/src/animation/generators/spring/index.ts +++ b/packages/motion-dom/src/animation/generators/spring.ts @@ -2,6 +2,7 @@ import { clamp, millisecondsToSeconds, secondsToMilliseconds, + warning, } from "motion-utils" import { AnimationState, @@ -9,16 +10,156 @@ import { SpringOptions, Transition, ValueAnimationOptions, -} from "../../types" -import { generateLinearEasing } from "../../waapi/utils/linear" +} from "../types" +import { generateLinearEasing } from "../waapi/utils/linear" import { calcGeneratorDuration, maxGeneratorDuration, -} from "../utils/calc-duration" -import { createGeneratorEasing } from "../utils/create-generator-easing" -import { calcGeneratorVelocity } from "../utils/velocity" -import { springDefaults } from "./defaults" -import { calcAngularFreq, findSpring } from "./find" +} from "./utils/calc-duration" +import { createGeneratorEasing } from "./utils/create-generator-easing" + +const springDefaults = { + // Default spring physics + stiffness: 100, + damping: 10, + mass: 1.0, + velocity: 0.0, + + // Default duration/bounce-based options + duration: 800, // in ms + bounce: 0.3, + visualDuration: 0.3, // in seconds + + // Rest thresholds + restSpeed: { + granular: 0.01, + default: 2, + }, + restDelta: { + granular: 0.005, + default: 0.5, + }, + + // Limits + minDuration: 0.01, // in seconds + maxDuration: 10.0, // in seconds + minDamping: 0.05, + maxDamping: 1, +} + +function calcAngularFreq(undampedFreq: number, dampingRatio: number) { + return undampedFreq * Math.sqrt(1 - dampingRatio * dampingRatio) +} + +const rootIterations = 12 +function approximateRoot( + envelope: (num: number) => number, + derivative: (num: number) => number, + initialGuess: number +): number { + let result = initialGuess + for (let i = 1; i < rootIterations; i++) { + result = result - envelope(result) / derivative(result) + } + return result +} + +/** + * This is ported from the Framer implementation of duration-based spring resolution. + */ +const safeMin = 0.001 + +function findSpring({ + duration = springDefaults.duration, + bounce = springDefaults.bounce, + velocity = springDefaults.velocity, + mass = springDefaults.mass, +}: SpringOptions) { + let envelope: (num: number) => number + let derivative: (num: number) => number + + warning( + duration <= secondsToMilliseconds(springDefaults.maxDuration), + "Spring duration must be 10 seconds or less", + "spring-duration-limit" + ) + + let dampingRatio = 1 - bounce + + /** + * Restrict dampingRatio and duration to within acceptable ranges. + */ + dampingRatio = clamp( + springDefaults.minDamping, + springDefaults.maxDamping, + dampingRatio + ) + duration = clamp( + springDefaults.minDuration, + springDefaults.maxDuration, + millisecondsToSeconds(duration) + ) + + if (dampingRatio < 1) { + /** + * Underdamped spring + */ + envelope = (undampedFreq) => { + const exponentialDecay = undampedFreq * dampingRatio + const delta = exponentialDecay * duration + const a = exponentialDecay - velocity + const b = calcAngularFreq(undampedFreq, dampingRatio) + const c = Math.exp(-delta) + return safeMin - (a / b) * c + } + + derivative = (undampedFreq) => { + const exponentialDecay = undampedFreq * dampingRatio + const delta = exponentialDecay * duration + const d = delta * velocity + velocity + const e = + Math.pow(dampingRatio, 2) * Math.pow(undampedFreq, 2) * duration + const f = Math.exp(-delta) + const g = calcAngularFreq(Math.pow(undampedFreq, 2), dampingRatio) + const factor = -envelope(undampedFreq) + safeMin > 0 ? -1 : 1 + return (factor * ((d - e) * f)) / g + } + } else { + /** + * Critically-damped spring + */ + envelope = (undampedFreq) => { + const a = Math.exp(-undampedFreq * duration) + const b = (undampedFreq - velocity) * duration + 1 + return -safeMin + a * b + } + + derivative = (undampedFreq) => { + const a = Math.exp(-undampedFreq * duration) + const b = (velocity - undampedFreq) * (duration * duration) + return a * b + } + } + + const initialGuess = 5 / duration + const undampedFreq = approximateRoot(envelope, derivative, initialGuess) + + duration = secondsToMilliseconds(duration) + if (isNaN(undampedFreq)) { + return { + stiffness: springDefaults.stiffness, + damping: springDefaults.damping, + duration, + } + } else { + const stiffness = Math.pow(undampedFreq, 2) * mass + return { + stiffness, + damping: dampingRatio * 2 * Math.sqrt(mass * stiffness), + duration, + } + } +} const durationKeys = ["duration", "bounce"] const physicsKeys = ["stiffness", "damping", "mass"] @@ -213,11 +354,10 @@ function spring( (initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / dampedAngularFreq - const Q = initialDelta const sinhCoeff = - dampingRatio * undampedAngularFreq * P - Q * dampedAngularFreq + dampingRatio * undampedAngularFreq * P - initialDelta * dampedAngularFreq const coshCoeff = - dampingRatio * undampedAngularFreq * Q - P * dampedAngularFreq + dampingRatio * undampedAngularFreq * initialDelta - P * dampedAngularFreq resolveVelocity = (t: number) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t) const freqForT = Math.min(dampedAngularFreq * t, 300) @@ -234,7 +374,7 @@ function spring( const current = resolveSpring(t) if (!isResolvedFromDuration) { - let currentVelocity = t === 0 ? initialVelocity : 0.0 + let currentVelocity = 0.0 /** * We only need to calculate velocity for under-damped springs @@ -243,9 +383,7 @@ function spring( */ if (dampingRatio < 1) { currentVelocity = - t === 0 - ? secondsToMilliseconds(initialVelocity) - : calcGeneratorVelocity(resolveSpring, t, current) + secondsToMilliseconds(resolveVelocity(t)) } const isBelowVelocityThreshold = diff --git a/packages/motion-dom/src/animation/generators/spring/defaults.ts b/packages/motion-dom/src/animation/generators/spring/defaults.ts deleted file mode 100644 index f90863022f..0000000000 --- a/packages/motion-dom/src/animation/generators/spring/defaults.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const springDefaults = { - // Default spring physics - stiffness: 100, - damping: 10, - mass: 1.0, - velocity: 0.0, - - // Default duration/bounce-based options - duration: 800, // in ms - bounce: 0.3, - visualDuration: 0.3, // in seconds - - // Rest thresholds - restSpeed: { - granular: 0.01, - default: 2, - }, - restDelta: { - granular: 0.005, - default: 0.5, - }, - - // Limits - minDuration: 0.01, // in seconds - maxDuration: 10.0, // in seconds - minDamping: 0.05, - maxDamping: 1, -} diff --git a/packages/motion-dom/src/animation/generators/spring/find.ts b/packages/motion-dom/src/animation/generators/spring/find.ts deleted file mode 100644 index 56e0390935..0000000000 --- a/packages/motion-dom/src/animation/generators/spring/find.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - clamp, - millisecondsToSeconds, - secondsToMilliseconds, - warning, -} from "motion-utils" -import { SpringOptions } from "../../types" -import { springDefaults } from "./defaults" - -/** - * This is ported from the Framer implementation of duration-based spring resolution. - */ - -type Resolver = (num: number) => number - -const safeMin = 0.001 - -export function findSpring({ - duration = springDefaults.duration, - bounce = springDefaults.bounce, - velocity = springDefaults.velocity, - mass = springDefaults.mass, -}: SpringOptions) { - let envelope: Resolver - let derivative: Resolver - - warning( - duration <= secondsToMilliseconds(springDefaults.maxDuration), - "Spring duration must be 10 seconds or less", - "spring-duration-limit" - ) - - let dampingRatio = 1 - bounce - - /** - * Restrict dampingRatio and duration to within acceptable ranges. - */ - dampingRatio = clamp( - springDefaults.minDamping, - springDefaults.maxDamping, - dampingRatio - ) - duration = clamp( - springDefaults.minDuration, - springDefaults.maxDuration, - millisecondsToSeconds(duration) - ) - - if (dampingRatio < 1) { - /** - * Underdamped spring - */ - envelope = (undampedFreq) => { - const exponentialDecay = undampedFreq * dampingRatio - const delta = exponentialDecay * duration - const a = exponentialDecay - velocity - const b = calcAngularFreq(undampedFreq, dampingRatio) - const c = Math.exp(-delta) - return safeMin - (a / b) * c - } - - derivative = (undampedFreq) => { - const exponentialDecay = undampedFreq * dampingRatio - const delta = exponentialDecay * duration - const d = delta * velocity + velocity - const e = - Math.pow(dampingRatio, 2) * Math.pow(undampedFreq, 2) * duration - const f = Math.exp(-delta) - const g = calcAngularFreq(Math.pow(undampedFreq, 2), dampingRatio) - const factor = -envelope(undampedFreq) + safeMin > 0 ? -1 : 1 - return (factor * ((d - e) * f)) / g - } - } else { - /** - * Critically-damped spring - */ - envelope = (undampedFreq) => { - const a = Math.exp(-undampedFreq * duration) - const b = (undampedFreq - velocity) * duration + 1 - return -safeMin + a * b - } - - derivative = (undampedFreq) => { - const a = Math.exp(-undampedFreq * duration) - const b = (velocity - undampedFreq) * (duration * duration) - return a * b - } - } - - const initialGuess = 5 / duration - const undampedFreq = approximateRoot(envelope, derivative, initialGuess) - - duration = secondsToMilliseconds(duration) - if (isNaN(undampedFreq)) { - return { - stiffness: springDefaults.stiffness, - damping: springDefaults.damping, - duration, - } - } else { - const stiffness = Math.pow(undampedFreq, 2) * mass - return { - stiffness, - damping: dampingRatio * 2 * Math.sqrt(mass * stiffness), - duration, - } - } -} - -const rootIterations = 12 -function approximateRoot( - envelope: Resolver, - derivative: Resolver, - initialGuess: number -): number { - let result = initialGuess - for (let i = 1; i < rootIterations; i++) { - result = result - envelope(result) / derivative(result) - } - return result -} - -export function calcAngularFreq(undampedFreq: number, dampingRatio: number) { - return undampedFreq * Math.sqrt(1 - dampingRatio * dampingRatio) -} diff --git a/packages/motion-dom/src/animation/generators/spring/utils.ts b/packages/motion-dom/src/animation/generators/spring/utils.ts deleted file mode 100644 index 0519ecba6e..0000000000 --- a/packages/motion-dom/src/animation/generators/spring/utils.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 02d8c156dcd0817480a397858a38bb4ae6d45349 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 6 Mar 2026 15:35:41 +0100 Subject: [PATCH 5/7] Rename calcGeneratorVelocity to getGeneratorVelocity Co-Authored-By: Claude Opus 4.6 --- packages/motion-dom/src/animation/JSAnimation.ts | 4 ++-- packages/motion-dom/src/animation/generators/inertia.ts | 4 ++-- .../motion-dom/src/animation/generators/utils/velocity.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index 5e64c0d141..aea83f49f9 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -14,7 +14,7 @@ import { DriverControls } from "./drivers/types" import { inertia } from "./generators/inertia" import { keyframes as keyframesGenerator } from "./generators/keyframes" import { calcGeneratorDuration } from "./generators/utils/calc-duration" -import { calcGeneratorVelocity } from "./generators/utils/velocity" +import { getGeneratorVelocity } from "./generators/utils/velocity" import { getFinalKeyframe } from "./keyframes/get-final" import { AnimationPlaybackControlsWithThen, @@ -400,7 +400,7 @@ export class JSAnimation // Fallback: finite difference const current = this.generator.next(t).value as number - return calcGeneratorVelocity( + return getGeneratorVelocity( (s) => this.generator.next(s).value as number, t, current diff --git a/packages/motion-dom/src/animation/generators/inertia.ts b/packages/motion-dom/src/animation/generators/inertia.ts index 85253a752f..bbb40d3493 100644 --- a/packages/motion-dom/src/animation/generators/inertia.ts +++ b/packages/motion-dom/src/animation/generators/inertia.ts @@ -4,7 +4,7 @@ import { ValueAnimationOptions, } from "../types" import { spring as createSpring } from "./spring" -import { calcGeneratorVelocity } from "./utils/velocity" +import { getGeneratorVelocity } from "./utils/velocity" export function inertia({ keyframes, @@ -73,7 +73,7 @@ export function inertia({ spring = createSpring({ keyframes: [state.value, nearestBoundary(state.value)!], - velocity: calcGeneratorVelocity(calcLatest, t, state.value), // TODO: This should be passing * 1000 + velocity: getGeneratorVelocity(calcLatest, t, state.value), // TODO: This should be passing * 1000 damping: bounceDamping, stiffness: bounceStiffness, restDelta, diff --git a/packages/motion-dom/src/animation/generators/utils/velocity.ts b/packages/motion-dom/src/animation/generators/utils/velocity.ts index 35b5f9f96a..6fa1135e79 100644 --- a/packages/motion-dom/src/animation/generators/utils/velocity.ts +++ b/packages/motion-dom/src/animation/generators/utils/velocity.ts @@ -2,7 +2,7 @@ import { velocityPerSecond } from "motion-utils" const velocitySampleDuration = 5 // ms -export function calcGeneratorVelocity( +export function getGeneratorVelocity( resolveValue: (v: number) => number, t: number, current: number From ee957879493ceeecfb121ed52b3f1b9a7bad3f0d Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Mar 2026 06:21:28 +0100 Subject: [PATCH 6/7] Fix regressions and optimize underdamped spring hot path - Restore isInDelayPhase guard on mixKeyframes (regressed #3351) - Restore time setter else-branch for driverless case (regressed #3269) - Inline shared trig computation in next() for underdamped springs, eliminating duplicate Math.exp/sin/cos calls per frame Co-Authored-By: Claude Opus 4.6 --- .../src/animation/generators/spring.ts | 63 ++++++++++++------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/motion-dom/src/animation/generators/spring.ts b/packages/motion-dom/src/animation/generators/spring.ts index d3701c3614..e8b6d9de9f 100644 --- a/packages/motion-dom/src/animation/generators/spring.ts +++ b/packages/motion-dom/src/animation/generators/spring.ts @@ -281,10 +281,17 @@ function spring( 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 + if (dampingRatio < 1) { - const angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio) + angularFreq = calcAngularFreq(undampedAngularFreq, dampingRatio) - const A = + A = (initialVelocity + dampingRatio * undampedAngularFreq * initialDelta) / angularFreq @@ -302,9 +309,9 @@ function spring( } // Analytical derivative of underdamped spring (px/ms) - const sinCoeff = + sinCoeff = dampingRatio * undampedAngularFreq * A + initialDelta * angularFreq - const cosCoeff = + cosCoeff = dampingRatio * undampedAngularFreq * initialDelta - A * angularFreq resolveVelocity = (t: number) => { const envelope = Math.exp(-dampingRatio * undampedAngularFreq * t) @@ -371,28 +378,40 @@ function spring( calculatedDuration: isResolvedFromDuration ? duration || null : null, velocity: (t: number) => secondsToMilliseconds(resolveVelocity(t)), next: (t: number) => { - const current = resolveSpring(t) + /** + * For underdamped physics springs we need both position and + * velocity each tick. Compute shared trig values once to avoid + * duplicate Math.exp/sin/cos calls on the hot path. + */ + if (!isResolvedFromDuration && dampingRatio < 1) { + const envelope = Math.exp( + -dampingRatio * undampedAngularFreq * t + ) + const sin = Math.sin(angularFreq * t) + const cos = Math.cos(angularFreq * t) + + const current = + target - + envelope * + (A * sin + initialDelta * cos) + const currentVelocity = secondsToMilliseconds( + envelope * + (sinCoeff * sin + cosCoeff * cos) + ) - if (!isResolvedFromDuration) { - let currentVelocity = 0.0 - - /** - * We only need to calculate velocity for under-damped springs - * as over- and critically-damped springs can't overshoot, so - * checking only for displacement is enough. - */ - if (dampingRatio < 1) { - currentVelocity = - secondsToMilliseconds(resolveVelocity(t)) - } - - const isBelowVelocityThreshold = - Math.abs(currentVelocity) <= restSpeed! - const isBelowDisplacementThreshold = + state.done = + Math.abs(currentVelocity) <= restSpeed! && Math.abs(target - current) <= restDelta! + state.value = state.done ? target : current + return state + } + + const current = resolveSpring(t) + + if (!isResolvedFromDuration) { state.done = - isBelowVelocityThreshold && isBelowDisplacementThreshold + Math.abs(target - current) <= restDelta! } else { state.done = t >= duration! } From 2c38dbb7604ace366cae2084972e2f4f27897ba0 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Mar 2026 06:53:22 +0100 Subject: [PATCH 7/7] Convert generatorVelocity getter to method, tighten framerate test - Change `get generatorVelocity()` to `getGeneratorVelocity()` method - Tighten 240hz vs 60hz test tolerance from 15% to 10% Co-Authored-By: Claude Opus 4.6 --- packages/motion-dom/src/animation/JSAnimation.ts | 2 +- .../src/value/__tests__/follow-value-framerate.test.ts | 6 +++--- packages/motion-dom/src/value/follow-value.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index aea83f49f9..8f779c6540 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -390,7 +390,7 @@ export class JSAnimation * Uses the analytical derivative when available (springs), avoiding * the MotionValue's frame-dependent velocity estimation. */ - get generatorVelocity(): number { + getGeneratorVelocity(): number { const t = this.currentTime if (t <= 0) return this.options.velocity || 0 diff --git a/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts index f7a5837228..280e578fa5 100644 --- a/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts +++ b/packages/motion-dom/src/value/__tests__/follow-value-framerate.test.ts @@ -81,9 +81,9 @@ describe("Spring follow at different frame rates (issue #3265)", () => { // Both frame rates should produce similar spring positions. // Before the fix, 240hz was ~34% behind 60hz. - // After the fix, they should be within 15% of each other. + // After the fix, they should be within 10% of each other. const ratio = pos240 / pos60 - expect(ratio).toBeGreaterThan(0.85) - expect(ratio).toBeLessThan(1.15) + expect(ratio).toBeGreaterThan(0.9) + expect(ratio).toBeLessThan(1.1) }) }) diff --git a/packages/motion-dom/src/value/follow-value.ts b/packages/motion-dom/src/value/follow-value.ts index 0a61787952..b48032b7c8 100644 --- a/packages/motion-dom/src/value/follow-value.ts +++ b/packages/motion-dom/src/value/follow-value.ts @@ -90,7 +90,7 @@ export function attachFollow( // falling back to the MotionValue's velocity for the initial animation. // This prevents systematic velocity loss at high frame rates (240hz+). const velocity = activeAnimation - ? activeAnimation.generatorVelocity + ? activeAnimation.getGeneratorVelocity() : value.getVelocity() stopAnimation()