From bebd7f5bf92b0899719cdb8da2114892983b02c2 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 19 Feb 2026 11:28:00 +0100 Subject: [PATCH 1/4] Ignore velocity for time-defined springs to prevent wild oscillation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an animation is interrupted, the motionValue carries velocity from the in-progress animation. For time-defined springs (duration/bounce), this velocity was passed to findSpring() which used it to compute stiffness and damping, fundamentally changing the spring's character. On small-range animations (e.g. opacity 0.5→0.49), even modest inherited velocity caused findSpring() to produce wildly inappropriate parameters, leading to massive oscillation. Physics-defined springs (stiffness/damping/mass) are unaffected — velocity correctly acts as an initial condition on fixed spring parameters. Co-Authored-By: Claude Sonnet 4.6 --- .../src/tests/layout-appear-spring-bounce.tsx | 109 ++++++++++++++++++ .../layout-appear-spring-bounce.ts | 30 +++++ .../generators/__tests__/spring.test.ts | 46 +++++++- .../src/animation/generators/spring/index.ts | 8 +- 4 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 dev/react/src/tests/layout-appear-spring-bounce.tsx create mode 100644 packages/framer-motion/cypress/integration/layout-appear-spring-bounce.ts diff --git a/dev/react/src/tests/layout-appear-spring-bounce.tsx b/dev/react/src/tests/layout-appear-spring-bounce.tsx new file mode 100644 index 0000000000..93e7e2052a --- /dev/null +++ b/dev/react/src/tests/layout-appear-spring-bounce.tsx @@ -0,0 +1,109 @@ +import { animate, motion, useMotionValue } from "framer-motion" +import { useEffect, useRef } from "react" + +/** + * Reproduces the bug where a time-defined spring receives velocity from + * an interrupted animation, causing wild oscillation. + * + * The Framer scenario: + * - Appear effect animates opacity 0.001 → 1 with time-defined spring + * - On hover, opacity → 0.49 with same spring + * - WAAPI appear animation sets velocity on motionValue when stopped + * - Hover animation reads velocity, passes it to findSpring() + * - findSpring() computes wrong spring parameters → wild oscillation + * + * This test uses external motionValues (no WAAPI owner → JS animation) + * with explicit velocity injection to simulate the WAAPI handoff. + */ + +const springTransition = { + type: "spring" as const, + duration: 0.4, + bounce: 0.2, +} + +export const App = () => + +function ExternalMotionValueMode() { + // Start at 0.5 (simulating appear animation mid-flight) + const opacity = useMotionValue(0.5) + const scale = useMotionValue(1) + const trackerRef = useRef(null) + + useEffect(() => { + // Simulate WAAPI handoff: inject velocity as if appear animation + // was stopped mid-flight (NativeAnimationExtended.updateMotionValue + // calls setWithVelocity on the motionValue) + const sampleDelta = 10 // ms, same as NativeAnimationExtended + opacity.setWithVelocity( + 0.45, // prev sample (velocity ~5/s upward) + 0.5, // current + sampleDelta + ) + + // Start hover animation — reads velocity from motionValue + animate(opacity, 0.49, springTransition) + animate(scale, 1.1, springTransition) + + // Track min/max values during hover animation + let minOpacity = 0.5 + let maxOpacity = 0.5 + let minScale = 1 + let maxScale = 1 + + const unsubOpacity = opacity.on("change", (v) => { + if (v < minOpacity) minOpacity = v + if (v > maxOpacity) maxOpacity = v + if (trackerRef.current) { + trackerRef.current.dataset.minOpacity = minOpacity.toFixed(4) + trackerRef.current.dataset.maxOpacity = maxOpacity.toFixed(4) + } + }) + + const unsubScale = scale.on("change", (v) => { + if (v < minScale) minScale = v + if (v > maxScale) maxScale = v + if (trackerRef.current) { + trackerRef.current.dataset.minScale = minScale.toFixed(4) + trackerRef.current.dataset.maxScale = maxScale.toFixed(4) + } + }) + + return () => { + unsubOpacity() + unsubScale() + } + }, []) + + return ( + <> +
+ + + + + ) +} diff --git a/packages/framer-motion/cypress/integration/layout-appear-spring-bounce.ts b/packages/framer-motion/cypress/integration/layout-appear-spring-bounce.ts new file mode 100644 index 0000000000..3d7b849760 --- /dev/null +++ b/packages/framer-motion/cypress/integration/layout-appear-spring-bounce.ts @@ -0,0 +1,30 @@ +describe("Time-defined spring with inherited velocity", () => { + it("Doesn't wildly oscillate when velocity is inherited from interrupted animation", () => { + /** + * Reproduces the Framer bug: + * 1. Appear animation sets velocity on motionValue when stopped + * 2. Hover animation reads velocity and passes to time-defined spring + * 3. findSpring() computes wrong parameters → wild oscillation + * + * Opacity starts at 0.5 with +5/s velocity (simulating interrupted + * appear). Hover targets 0.49. Without fix, opacity shoots up to + * ~0.58+ before settling. With fix, it stays near 0.5. + */ + cy.visit("?test=layout-appear-spring-bounce") + .wait(1500) + .get("#tracker") + .should(([$tracker]: any) => { + const maxOpacity = Number($tracker.dataset.maxOpacity) + + // Opacity starts at 0.5, targets 0.49 (tiny delta of 0.01) + // A well-behaved spring should barely overshoot above 0.5 + // Bug: velocity causes overshoot to ~0.58+ + // Fixed: maxOpacity stays near 0.5 + expect(maxOpacity).to.be.lessThan( + 0.55, + `Opacity overshot to ${maxOpacity} (start: 0.5, target: 0.49). ` + + `Time-defined spring should ignore inherited velocity.` + ) + }) + }) +}) 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 5fc81e6aec..35eb0c0b34 100644 --- a/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts +++ b/packages/motion-dom/src/animation/generators/__tests__/spring.test.ts @@ -141,16 +141,54 @@ describe("spring", () => { expect(withoutDuration.length).toBeGreaterThan(4) }) - test("Spring defined as bounce and duration is resolved with correct velocity", () => { + test("Time-defined spring ignores velocity", () => { const settings = { keyframes: [500, 10], bounce: 0.2, duration: 1000, } - const resolvedSpring = spring({ ...settings, velocity: 1000 }) + const withVelocity = spring({ ...settings, velocity: 1000 }) + const withoutVelocity = spring(settings) - expect(resolvedSpring.next(0).value).toBe(500) - expect(Math.floor(resolvedSpring.next(100).value)).toBe(420) + // Time-defined springs ignore velocity to prevent wild oscillation + // from interrupted animations + expect(withVelocity.next(0).value).toBe(withoutVelocity.next(0).value) + expect(withVelocity.next(100).value).toBe( + withoutVelocity.next(100).value + ) + }) + + test("Time-defined spring with velocity does not wildly oscillate", () => { + /** + * Time-defined springs (duration/bounce) must ignore inherited + * velocity. When an animation is interrupted, the motionValue + * carries velocity from the in-progress animation. If this leaks + * into findSpring(), it changes the computed spring parameters + * and causes massive oscillation on small-range animations. + */ + const settings = { + keyframes: [0, 100], + bounce: 0.2, + duration: 400, + } + + const noVelocity = spring(settings) + const withVelocity = spring({ ...settings, velocity: 5000 }) + + let maxNoVelocity = 0 + let maxWithVelocity = 0 + + for (let t = 0; t <= 400; t += 5) { + const noVel = noVelocity.next(t).value + const withVel = withVelocity.next(t).value + + if (noVel > maxNoVelocity) maxNoVelocity = noVel + if (withVel > maxWithVelocity) maxWithVelocity = withVel + } + + // Both should have identical mild overshoot (velocity is ignored) + expect(maxNoVelocity - 100).toBeLessThan(5) + expect(maxWithVelocity - 100).toBeLessThan(5) }) test("Spring animating back to same number returns correct duration", () => { diff --git a/packages/motion-dom/src/animation/generators/spring/index.ts b/packages/motion-dom/src/animation/generators/spring/index.ts index 798edbb5ad..faa17eefaf 100644 --- a/packages/motion-dom/src/animation/generators/spring/index.ts +++ b/packages/motion-dom/src/animation/generators/spring/index.ts @@ -41,6 +41,12 @@ function getSpringOptions(options: SpringOptions) { !isSpringType(options, physicsKeys) && isSpringType(options, durationKeys) ) { + // Time-defined springs should ignore inherited velocity. + // Velocity from interrupted animations can cause findSpring() + // to compute wildly different spring parameters, leading to + // massive oscillation on small-range animations. + springOptions.velocity = 0 + if (options.visualDuration) { const visualDuration = options.visualDuration const root = (2 * Math.PI) / (visualDuration * 1.2) @@ -57,7 +63,7 @@ function getSpringOptions(options: SpringOptions) { damping, } } else { - const derived = findSpring(options) + const derived = findSpring({ ...options, velocity: 0 }) springOptions = { ...springOptions, From ab73833f4dfcba3449ac7c829e77b927ef39c34f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 20 Feb 2026 10:43:22 +0100 Subject: [PATCH 2/4] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561c903e8d..20d774cce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.34.3] 2026-02-20 + +### Fixed + +- Ensure `velocity` is never transferred to a time-derived spring. + ## [12.34.2] 2026-02-18 ### Fixed From 071f659f02ecc4ceb21ae17a74085454b961c585 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 20 Feb 2026 10:43:38 +0100 Subject: [PATCH 3/4] v12.34.3 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index e3886a6c42..df2d80637a 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.34.2", + "version": "12.34.3", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.34.2", - "motion": "^12.34.2", - "motion-dom": "^12.34.2", + "framer-motion": "^12.34.3", + "motion": "^12.34.3", + "motion-dom": "^12.34.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index be434805a6..b18a0322b4 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.34.2", + "version": "12.34.3", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.34.2", + "motion": "^12.34.3", "next": "15.5.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 783ae187ce..cc925c6d4e 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.34.2", + "version": "12.34.3", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.34.2", + "motion": "^12.34.3", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 40a9a13c31..90e6316b5c 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.34.2", + "version": "12.34.3", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.34.2", + "framer-motion": "^12.34.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index cb3ba270a4..01ade49e7f 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.34.2", + "version": "12.34.3", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 6b6a95a904..2d774d979a 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.34.2", + "version": "12.34.3", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.34.2", + "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 370844a669..154a8eff82 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.34.2", + "version": "12.34.3", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 69bf7ede67..b9ce4bbee9 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.34.2", + "version": "12.34.3", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.34.2", + "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index f59f09d3ed..1d66f597ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.34.2, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.34.3, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.34.2 + motion-dom: ^12.34.3 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8209,9 +8209,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.34.2 - motion: ^12.34.2 - motion-dom: ^12.34.2 + framer-motion: ^12.34.3 + motion: ^12.34.3 + motion-dom: ^12.34.3 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10953,7 +10953,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.34.2, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.34.3, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11032,11 +11032,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.34.2, motion@workspace:packages/motion": +"motion@^12.34.3, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.34.2 + framer-motion: ^12.34.3 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11153,7 +11153,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.34.2 + motion: ^12.34.3 next: 15.5.10 react: 19.0.0 react-dom: 19.0.0 @@ -12625,7 +12625,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.34.2 + motion: ^12.34.3 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12709,7 +12709,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.34.2 + framer-motion: ^12.34.3 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 2a578eceffa05a524c7c62c09616daea84e72406 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 20 Feb 2026 10:50:21 +0100 Subject: [PATCH 4/4] Remove unnecessary passive: true from scroll and resize listeners These events are not cancelable so the passive option has no effect. Co-Authored-By: Claude Opus 4.6 --- packages/framer-motion/src/gestures/pan/PanSession.ts | 5 +---- packages/framer-motion/src/render/dom/scroll/track.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/framer-motion/src/gestures/pan/PanSession.ts b/packages/framer-motion/src/gestures/pan/PanSession.ts index 006b7c065f..afac94615e 100644 --- a/packages/framer-motion/src/gestures/pan/PanSession.ts +++ b/packages/framer-motion/src/gestures/pan/PanSession.ts @@ -194,13 +194,10 @@ export class PanSession { // Capture listener catches element scroll events as they bubble window.addEventListener("scroll", this.onElementScroll, { capture: true, - passive: true, }) // Direct window scroll listener (window scroll doesn't bubble) - window.addEventListener("scroll", this.onWindowScroll, { - passive: true, - }) + window.addEventListener("scroll", this.onWindowScroll) this.removeScrollListeners = () => { window.removeEventListener("scroll", this.onElementScroll, { diff --git a/packages/framer-motion/src/render/dom/scroll/track.ts b/packages/framer-motion/src/render/dom/scroll/track.ts index 4326a6b121..e6cbf382c5 100644 --- a/packages/framer-motion/src/render/dom/scroll/track.ts +++ b/packages/framer-motion/src/render/dom/scroll/track.ts @@ -72,12 +72,12 @@ export function scrollInfo( scrollListeners.set(container, listener) const target = getEventTarget(container) - window.addEventListener("resize", listener, { passive: true }) + window.addEventListener("resize", listener) if (container !== document.documentElement) { resizeListeners.set(container, resize(container, listener)) } - target.addEventListener("scroll", listener, { passive: true }) + target.addEventListener("scroll", listener) listener() }