Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions dev/html/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "html-env",
"private": true,
"version": "12.34.2",
"version": "12.34.3",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions dev/next/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "next-env",
"private": true,
"version": "12.34.2",
"version": "12.34.3",
"type": "module",
"scripts": {
"dev": "next dev",
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions dev/react-19/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-19-env",
"private": true,
"version": "12.34.2",
"version": "12.34.3",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions dev/react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-env",
"private": true,
"version": "12.34.2",
"version": "12.34.3",
"type": "module",
"scripts": {
"dev": "yarn vite",
Expand All @@ -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"
},
Expand Down
109 changes: 109 additions & 0 deletions dev/react/src/tests/layout-appear-spring-bounce.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => <ExternalMotionValueMode />

function ExternalMotionValueMode() {
// Start at 0.5 (simulating appear animation mid-flight)
const opacity = useMotionValue(0.5)
const scale = useMotionValue(1)
const trackerRef = useRef<HTMLDivElement>(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 (
<>
<div id="tracker" ref={trackerRef} />
<motion.div
style={{
position: "absolute",
top: 50,
left: 50,
width: 231,
height: 231,
backgroundColor: "rgb(153, 238, 255)",
}}
>
<motion.div
id="box"
style={{
width: 115,
height: 106,
backgroundColor: "rgb(68, 204, 255)",
position: "absolute",
top: "50%",
left: "50%",
x: "-50%",
y: "-50%",
opacity,
scale,
}}
/>
</motion.div>
</>
)
}
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "12.34.2",
"version": "12.34.3",
"packages": [
"packages/*",
"dev/*"
Expand Down
Original file line number Diff line number Diff line change
@@ -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.`
)
})
})
})
4 changes: 2 additions & 2 deletions packages/framer-motion/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
5 changes: 1 addition & 4 deletions packages/framer-motion/src/gestures/pan/PanSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
4 changes: 2 additions & 2 deletions packages/framer-motion/src/render/dom/scroll/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion packages/motion-dom/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/motion-dom/src/animation/generators/spring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -57,7 +63,7 @@ function getSpringOptions(options: SpringOptions) {
damping,
}
} else {
const derived = findSpring(options)
const derived = findSpring({ ...options, velocity: 0 })

springOptions = {
...springOptions,
Expand Down
4 changes: 2 additions & 2 deletions packages/motion/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -76,7 +76,7 @@
"postpublish": "git push --tags"
},
"dependencies": {
"framer-motion": "^12.34.2",
"framer-motion": "^12.34.3",
"tslib": "^2.4.0"
},
"peerDependencies": {
Expand Down
Loading
Loading