From 9f228395ed60b34341c1ed07ec46c717fcb9bf85 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 14:14:06 +0100 Subject: [PATCH 01/35] fix(drag): Fix slow flick velocity and momentum carry-over after catch-and-release Fix velocity calculation for hold-then-flick gestures by skipping stale pointer-down origin points in PanSession.getVelocity(). Fix momentum resuming after catch-and-release by stopping animations on pointer down instead of pausing, and starting fresh constraint animations on release instead of resuming paused momentum. Co-Authored-By: Claude Opus 4.5 --- dev/react/src/tests/drag-momentum.tsx | 21 +++++++ .../cypress/integration/drag-momentum.ts | 62 +++++++++++++++++++ .../drag/VisualElementDragControls.ts | 31 +++------- .../src/gestures/pan/PanSession.ts | 16 +++++ 4 files changed, 106 insertions(+), 24 deletions(-) create mode 100644 dev/react/src/tests/drag-momentum.tsx create mode 100644 packages/framer-motion/cypress/integration/drag-momentum.ts diff --git a/dev/react/src/tests/drag-momentum.tsx b/dev/react/src/tests/drag-momentum.tsx new file mode 100644 index 0000000000..285ff48af5 --- /dev/null +++ b/dev/react/src/tests/drag-momentum.tsx @@ -0,0 +1,21 @@ +import { motion } from "framer-motion" + +export const App = () => { + return ( +
+ +
+ ) +} diff --git a/packages/framer-motion/cypress/integration/drag-momentum.ts b/packages/framer-motion/cypress/integration/drag-momentum.ts new file mode 100644 index 0000000000..d5b95d1842 --- /dev/null +++ b/packages/framer-motion/cypress/integration/drag-momentum.ts @@ -0,0 +1,62 @@ +describe("Drag Momentum", () => { + it("Fast flick after hold produces momentum", () => { + cy.visit("?test=drag-momentum") + .wait(200) + .get("[data-testid='draggable']") + .trigger("pointerdown", 25, 25) + .wait(300) // Simulate holding before flick + .trigger("pointermove", 30, 30) // Cross distance threshold + .wait(50) + .trigger("pointermove", 100, 25, { force: true }) // Quick flick + .wait(16) + .trigger("pointerup", { force: true }) + .wait(500) // Wait for momentum to carry element + .should(($draggable: any) => { + const draggable = $draggable[0] as HTMLDivElement + const { left } = draggable.getBoundingClientRect() + + // Element should have carried well past the release point + // due to momentum. Without the fix, velocity is diluted by + // the stale pointer-down point and momentum is minimal. + expect(left).to.be.greaterThan(300) + }) + }) + + it("Catch-and-release stops momentum", () => { + cy.visit("?test=drag-momentum") + .wait(200) + .get("[data-testid='draggable']") + // Perform a drag-and-throw + .trigger("pointerdown", 25, 25) + .trigger("pointermove", 30, 30) // Cross distance threshold + .wait(50) + .trigger("pointermove", 200, 25, { force: true }) + .wait(16) + .trigger("pointerup", { force: true }) + // Wait briefly for momentum to start + .wait(100) + // Record position, then catch and release + .then(($draggable: any) => { + const draggable = $draggable[0] as HTMLDivElement + const { left } = draggable.getBoundingClientRect() + // Store position for later comparison + $draggable.attr("data-caught-left", Math.round(left)) + }) + .trigger("pointerdown", 25, 25, { force: true }) + .wait(50) + .trigger("pointerup", { force: true }) + .wait(500) // Wait to see if element continues moving + .should(($draggable: any) => { + const draggable = $draggable[0] as HTMLDivElement + const { left } = draggable.getBoundingClientRect() + const caughtLeft = parseInt( + $draggable.attr("data-caught-left"), + 10 + ) + + // Element should stay near where it was caught, + // not continue with old momentum. + expect(Math.abs(left - caughtLeft)).to.be.lessThan(50) + }) + }) +}) diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 53b0284dca..749cc1b6f0 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -108,22 +108,13 @@ export class VisualElementDragControls { if (presenceContext && presenceContext.isPresent === false) return const onSessionStart = (event: PointerEvent) => { - // Stop or pause animations based on context: - // - snapToCursor: stop because we'll set new position values - // - otherwise: pause to allow resume if no drag starts (for constraint animations) if (snapToCursor) { - this.stopAnimation() this.snapToCursor(extractEventInfo(event).point) - } else { - this.pauseAnimation() } + this.stopAnimation() } const onStart = (event: PointerEvent, info: PanInfo) => { - // Stop any paused animation so motion values reflect true current position - // (pauseAnimation was called in onSessionStart to allow resume if no drag started) - this.stopAnimation() - // Attempt to grab the global drag gesture lock - maybe make this part of PanSession const { drag, dragPropagation, onDragStart } = this.getProps() @@ -243,12 +234,12 @@ export class VisualElementDragControls { this.latestPanInfo = null } - const resumeAnimation = () => - eachAxis( - (axis) => - this.getAnimationState(axis) === "paused" && - this.getAxisMotionValue(axis).animation?.play() - ) + const resumeAnimation = () => { + const { dragSnapToOrigin: snap } = this.getProps() + if (snap || this.constraints) { + this.startAnimation({ x: 0, y: 0 }) + } + } const { dragSnapToOrigin } = this.getProps() this.panSession = new PanSession( @@ -525,14 +516,6 @@ export class VisualElementDragControls { eachAxis((axis) => this.getAxisMotionValue(axis).stop()) } - private pauseAnimation() { - eachAxis((axis) => this.getAxisMotionValue(axis).animation?.pause()) - } - - private getAnimationState(axis: DragDirection) { - return this.getAxisMotionValue(axis).animation?.state - } - /** * Drag works differently depending on which props are provided. * diff --git a/packages/framer-motion/src/gestures/pan/PanSession.ts b/packages/framer-motion/src/gestures/pan/PanSession.ts index e9b01d8320..006b7c065f 100644 --- a/packages/framer-motion/src/gestures/pan/PanSession.ts +++ b/packages/framer-motion/src/gestures/pan/PanSession.ts @@ -382,6 +382,22 @@ function getVelocity(history: TimestampedPoint[], timeDelta: number): Point { return { x: 0, y: 0 } } + /** + * If the selected point is the pointer-down origin (history[0]), + * there are better movement points available, and the time gap + * is suspiciously large (>2x timeDelta), use the next point instead. + * This prevents stale pointer-down points from diluting velocity + * in hold-then-flick gestures. + */ + if ( + timestampedPoint === history[0] && + history.length > 2 && + lastPoint.timestamp - timestampedPoint.timestamp > + secondsToMilliseconds(timeDelta) * 2 + ) { + timestampedPoint = history[1] + } + const time = millisecondsToSeconds( lastPoint.timestamp - timestampedPoint.timestamp ) From cca23056f965f373ebc7d367ab8d0e5e0aa6eb78 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 14:55:56 +0100 Subject: [PATCH 02/35] test: Add shared layout animation Fragment test Add Cypress E2E test verifying that elements with layoutId inside a React Fragment animate from the correct starting position. Closes #1681 Co-Authored-By: Claude Opus 4.5 --- .../src/tests/layout-shared-fragment.tsx | 62 +++++++++++++++++++ .../integration/layout-shared-fragment.ts | 41 ++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 dev/react/src/tests/layout-shared-fragment.tsx create mode 100644 packages/framer-motion/cypress/integration/layout-shared-fragment.ts diff --git a/dev/react/src/tests/layout-shared-fragment.tsx b/dev/react/src/tests/layout-shared-fragment.tsx new file mode 100644 index 0000000000..06bc2f5e5d --- /dev/null +++ b/dev/react/src/tests/layout-shared-fragment.tsx @@ -0,0 +1,62 @@ +import { motion } from "framer-motion" +import { Fragment, useState } from "react" + +const box: React.CSSProperties = { + position: "absolute", + left: 0, + background: "red", +} + +const a: React.CSSProperties = { + ...box, + top: 100, + width: 100, + height: 100, +} + +const b: React.CSSProperties = { + ...box, + top: 300, + width: 100, + height: 100, +} + +function A({ onClick }: { onClick: () => void }) { + return ( + + 0.5 }} + /> + + ) +} + +function B({ onClick }: { onClick: () => void }) { + return ( + + 0.5 }} + /> + + ) +} + +export const App = () => { + const [state, setState] = useState(true) + + return state ? ( + setState(false)} /> + ) : ( + setState(true)} /> + ) +} diff --git a/packages/framer-motion/cypress/integration/layout-shared-fragment.ts b/packages/framer-motion/cypress/integration/layout-shared-fragment.ts new file mode 100644 index 0000000000..ec824c3964 --- /dev/null +++ b/packages/framer-motion/cypress/integration/layout-shared-fragment.ts @@ -0,0 +1,41 @@ +interface BoundingBox { + top: number + left: number + width: number + height: number +} + +function expectBbox(element: HTMLElement, expectedBbox: BoundingBox) { + const bbox = element.getBoundingClientRect() + expect(bbox.left).to.equal(expectedBbox.left) + expect(bbox.top).to.equal(expectedBbox.top) + expect(bbox.width).to.equal(expectedBbox.width) + expect(bbox.height).to.equal(expectedBbox.height) +} + +describe("Shared layout: Fragment", () => { + it("Elements with layoutId inside a Fragment should animate from the correct starting position", () => { + cy.visit("?test=layout-shared-fragment") + .wait(50) + .get("#box") + .should(([$box]: any) => { + expectBbox($box, { + top: 100, + left: 0, + width: 100, + height: 100, + }) + }) + .trigger("click") + .wait(200) + .get("#box") + .should(([$box]: any) => { + // At ease: () => 0.5, the element should be halfway + // between top: 100 and top: 300, i.e. top: 200. + // If the bug is present, it will start from top: 0 + // and be at top: 150 instead. + const bbox = $box.getBoundingClientRect() + expect(bbox.top).to.equal(200) + }) + }) +}) From 7323452823b78b8a553def783411577ae456c228 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 14:56:48 +0100 Subject: [PATCH 03/35] fix(types): Change onHoverStart/onHoverEnd event type from MouseEvent to PointerEvent The hover gesture uses pointerenter/pointerleave events, which produce PointerEvent objects at runtime. The type definitions incorrectly declared the callback parameter as MouseEvent. Fixes #2286 Co-Authored-By: Claude Opus 4.5 --- packages/motion-dom/src/node/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index d5aa01b977..c0405549ed 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -424,7 +424,7 @@ export interface MotionNodeHoverHandlers { * console.log('Hover starts')} /> * ``` */ - onHoverStart?(event: MouseEvent, info: EventInfo): void + onHoverStart?(event: PointerEvent, info: EventInfo): void /** * Callback function that fires when pointer stops hovering over the component. @@ -433,7 +433,7 @@ export interface MotionNodeHoverHandlers { * console.log("Hover ends")} /> * ``` */ - onHoverEnd?(event: MouseEvent, info: EventInfo): void + onHoverEnd?(event: PointerEvent, info: EventInfo): void } /** From 1fb784e1db25958bc903d9021965b0d4c38a2417 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 20:03:00 +0100 Subject: [PATCH 04/35] fix: Reset motion values to initial on remount after Suspense When a motion element suspends mid-animation via React Suspense and remounts, the VisualElement instance is preserved (via useRef) but motion values retain intermediate animation values. This causes animations to start from stuck intermediate states instead of replaying from initial. Track whether mount() has been called before via hasBeenMounted flag, and on remount, jump all motion values back to their initialValues before binding to the new DOM element. Co-Authored-By: Claude Opus 4.5 --- .../motion/__tests__/animate-prop.test.tsx | 65 ++++++++++++++++++- .../motion-dom/src/render/VisualElement.ts | 23 +++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx b/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx index 2f976ba2b4..a653144fb9 100644 --- a/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/animate-prop.test.tsx @@ -1,4 +1,4 @@ -import { createRef, useRef } from "react" +import { createRef, Suspense, useRef, useState } from "react" import { frame, motion, @@ -9,6 +9,7 @@ import { } from "../../" import { nextFrame } from "../../gestures/__tests__/utils" import { render } from "../../jest.setup" +import { act } from "react" describe("animate prop as object", () => { test("animates to set prop", async () => { @@ -1308,4 +1309,66 @@ describe("animate prop as object", () => { return expect(result).toBe(true) }) + + test("Resets motion values to initial after Suspense remount", async () => { + const opacity = motionValue(1) + const scale = motionValue(1) + + let triggerSuspense: () => void + let resolveSuspense: () => void + + // A component that suspends when triggered + const SuspendingChild = () => { + const [suspended, setSuspended] = useState(false) + triggerSuspense = () => setSuspended(true) + + if (suspended) { + throw new Promise((resolve) => { + resolveSuspense = () => { + setSuspended(false) + resolve() + } + }) + } + + return ( + + ) + } + + const Component = () => ( + Loading...}> + + + ) + + const { rerender } = render() + rerender() + + // Wait for initial animation to progress + await act(async () => { + await nextFrame() + await nextFrame() + }) + + // Trigger suspension mid-animation + await act(async () => { + triggerSuspense!() + }) + + // Resolve suspension to remount + await act(async () => { + resolveSuspense!() + }) + + // After remount, values should be reset to initial (not stuck at + // intermediate animation values) + expect(opacity.get()).toBe(0) + expect(scale.get()).toBe(0) + }) }) diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index deb8877ff8..19cdf58d70 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -355,6 +355,12 @@ export abstract class VisualElement< */ private initialValues: ResolvedValues + /** + * Track whether this element has been mounted before, to detect + * remounts after Suspense unmount/remount cycles. + */ + private hasBeenMounted = false + /** * An object containing a SubscriptionManager for each active event. */ @@ -428,6 +434,21 @@ export abstract class VisualElement< } mount(instance: Instance) { + /** + * If this element has been mounted before (e.g. after a Suspense + * unmount/remount), reset motion values to their initial state + * so animations replay correctly from initial → animate. + */ + if (this.hasBeenMounted && Object.keys(this.initialValues).length) { + for (const key in this.initialValues) { + const value = this.values.get(key) + if (value) { + value.jump(this.initialValues[key]) + } + this.latestValues[key] = this.initialValues[key] + } + } + this.current = instance visualElementStore.set(instance, this) @@ -474,6 +495,8 @@ export abstract class VisualElement< this.parent?.addChild(this) this.update(this.props, this.presenceContext) + + this.hasBeenMounted = true } unmount() { From 5587cb505df64b9aec84dcedffaf22d9358bba54 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 21:17:37 +0100 Subject: [PATCH 05/35] feat(MotionConfig): Add inheritTransition prop for transition merging Add opt-in `inheritTransition` prop to MotionConfig that shallow-merges its transition with the parent MotionConfig's transition instead of replacing it. This lets users set global defaults (duration, ease) via a parent MotionConfig while nested MotionConfigs add properties like delay without losing the parent's settings. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/MotionConfig.test.tsx | 88 ++++++++++++++++++- .../src/components/MotionConfig/index.tsx | 8 +- .../src/context/MotionConfigContext.tsx | 8 ++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx b/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx index c6fd10f174..bfb0d23f4d 100644 --- a/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx +++ b/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx @@ -1,5 +1,5 @@ import { render } from "@testing-library/react" -import { AnimationGeneratorName } from "motion-dom" +import { AnimationGeneratorName, Transition } from "motion-dom" import { useContext } from "react" import { MotionConfig } from ".." import { MotionConfigContext } from "../../../context/MotionConfigContext" @@ -13,6 +13,15 @@ const Consumer = () => { ) } +const TransitionConsumer = () => { + const value = useContext(MotionConfigContext) + return ( +
+ {JSON.stringify(value.transition)} +
+ ) +} + const App = ({ type }: { type: AnimationGeneratorName }) => ( @@ -31,3 +40,80 @@ it("Passes down transition changes", () => { expect(getByTestId(consumerId).textContent).toBe("tween") }) + +it("Nested MotionConfig without inheritTransition fully replaces parent transition", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.delay).toBe(0.5) + expect(transition.type).toBeUndefined() + expect(transition.duration).toBeUndefined() +}) + +it("Nested MotionConfig with inheritTransition shallow-merges with parent transition", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.type).toBe("spring") + expect(transition.duration).toBe(1) + expect(transition.delay).toBe(0.5) +}) + +it("inheritTransition inner keys win over parent keys", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.type).toBe("spring") + expect(transition.duration).toBe(2) + expect(transition.delay).toBe(0.5) +}) + +it("inheritTransition cascades through deeply nested MotionConfigs", () => { + const { getByTestId } = render( + + + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition.type).toBe("spring") + expect(transition.duration).toBe(1) + expect(transition.delay).toBe(0.5) + expect(transition.ease).toBe("easeIn") +}) diff --git a/packages/framer-motion/src/components/MotionConfig/index.tsx b/packages/framer-motion/src/components/MotionConfig/index.tsx index ead6133680..29ae5ea21a 100644 --- a/packages/framer-motion/src/components/MotionConfig/index.tsx +++ b/packages/framer-motion/src/components/MotionConfig/index.tsx @@ -41,7 +41,12 @@ export function MotionConfig({ /** * Inherit props from any parent MotionConfig components */ - config = { ...useContext(MotionConfigContext), ...config } + const parentConfig = useContext(MotionConfigContext) + config = { ...parentConfig, ...config } + + if (config.inheritTransition) { + config.transition = { ...parentConfig.transition, ...config.transition } + } /** * Don't allow isStatic to change between renders as it affects how many hooks @@ -60,6 +65,7 @@ export function MotionConfig({ config.transformPagePoint, config.reducedMotion, config.skipAnimations, + config.inheritTransition, ] ) diff --git a/packages/framer-motion/src/context/MotionConfigContext.tsx b/packages/framer-motion/src/context/MotionConfigContext.tsx index a527e48f72..0c47dd326d 100644 --- a/packages/framer-motion/src/context/MotionConfigContext.tsx +++ b/packages/framer-motion/src/context/MotionConfigContext.tsx @@ -52,6 +52,14 @@ export interface MotionConfigContext { * @public */ skipAnimations?: boolean + + /** + * If true, this MotionConfig's transition will be shallow-merged with the + * parent MotionConfig's transition instead of replacing it. + * + * @public + */ + inheritTransition?: boolean } /** From e3bf5011f960ab98716e57bd9bde12c0ad47bd24 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 14:14:10 +0100 Subject: [PATCH 06/35] fix(AnimatePresence): Support dynamic mode changes without breaking animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always render PopChild wrapper in PresenceChild to keep the React tree stable when AnimatePresence's mode prop changes dynamically (e.g., "wait" → "popLayout"). Add a `pop` prop to PopChild that controls whether measurement and CSS injection are active, preventing tree structure changes that cause React to unmount/remount inner components. Fixes #1717 Co-Authored-By: Claude Opus 4.5 --- .../components/AnimatePresence/PopChild.tsx | 13 +++-- .../AnimatePresence/PresenceChild.tsx | 12 ++--- .../__tests__/AnimatePresence.test.tsx | 53 +++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx index ba10491929..7b9852c1aa 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PopChild.tsx @@ -22,6 +22,7 @@ interface Props { anchorX?: "left" | "right" anchorY?: "top" | "bottom" root?: HTMLElement | ShadowRoot + pop?: boolean } interface MeasureProps extends Props { @@ -36,7 +37,7 @@ interface MeasureProps extends Props { class PopChildMeasure extends React.Component { getSnapshotBeforeUpdate(prevProps: MeasureProps) { const element = this.props.childRef.current - if (element && prevProps.isPresent && !this.props.isPresent) { + if (element && prevProps.isPresent && !this.props.isPresent && this.props.pop !== false) { const parent = element.offsetParent const parentWidth = isHTMLElement(parent) ? parent.offsetWidth || 0 @@ -67,7 +68,7 @@ class PopChildMeasure extends React.Component { } } -export function PopChild({ children, isPresent, anchorX, anchorY, root }: Props) { +export function PopChild({ children, isPresent, anchorX, anchorY, root, pop }: Props) { const id = useId() const ref = useRef(null) const size = useRef({ @@ -99,7 +100,7 @@ export function PopChild({ children, isPresent, anchorX, anchorY, root }: Props) */ useInsertionEffect(() => { const { width, height, top, left, right, bottom } = size.current - if (isPresent || !ref.current || !width || !height) return + if (isPresent || pop === false || !ref.current || !width || !height) return const x = anchorX === "left" ? `left: ${left}` : `right: ${right}` const y = anchorY === "bottom" ? `bottom: ${bottom}` : `top: ${top}` @@ -132,8 +133,10 @@ export function PopChild({ children, isPresent, anchorX, anchorY, root }: Props) }, [isPresent]) return ( - - {React.cloneElement(children as any, { ref: composedRef })} + + {pop === false + ? children + : React.cloneElement(children as any, { ref: composedRef })} ) } diff --git a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx index 5b933bd86f..8befa0690c 100644 --- a/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/PresenceChild.tsx @@ -86,13 +86,11 @@ export const PresenceChild = ({ onExitComplete() }, [isPresent]) - if (mode === "popLayout") { - children = ( - - {children} - - ) - } + children = ( + + {children} + + ) return ( diff --git a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx index 00071173fe..130861ebdf 100644 --- a/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx +++ b/packages/framer-motion/src/components/AnimatePresence/__tests__/AnimatePresence.test.tsx @@ -717,6 +717,58 @@ describe("AnimatePresence", () => { // The bottom position should be preserved (approximately 0) expect(initialBottom).toBeLessThanOrEqual(1) }) + + test("Switching mode from wait to popLayout doesn't break animations", async () => { + const opacity = motionValue(0) + const Component = ({ mode }: { mode: "wait" | "popLayout" }) => ( + + + + ) + + const { rerender } = render() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + + rerender() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + }) + + test("Switching mode from popLayout to wait doesn't break animations", async () => { + const opacity = motionValue(0) + const Component = ({ mode }: { mode: "wait" | "popLayout" }) => ( + + + + ) + + const { rerender } = render() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + + rerender() + rerender() + await nextFrame() + + expect(opacity.get()).toBe(1) + }) }) describe("AnimatePresence with custom components", () => { @@ -1107,6 +1159,7 @@ describe("AnimatePresence with custom components", () => { await new Promise(async (resolve) => { async function complete() { await nextFrame() + await nextFrame() expect(outerOpacity.get()).toBe(0) expect(innerOpacity.get()).toBe(1) From 8073386bfef2d22ff98be4e8075251593efc80dd Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 21:09:56 +0100 Subject: [PATCH 07/35] fix(drag): Use pointer capture to fix whileHover persisting after drag ends (#2316) When both whileHover and drag are used, hover state could get stuck after dragging if the pointer left the element during drag. The browser's pointerleave was blocked by isDragActive(), and after drag ended no new pointerleave would fire to clear hover. Use setPointerCapture during drag so the browser suppresses boundary events. On pointerup, capture is implicitly released and the browser re-evaluates pointer position, firing pointerleave if needed. Co-Authored-By: Claude Opus 4.5 --- .../src/gestures/__tests__/hover.test.tsx | 65 ++++++++++++++++++- .../drag/VisualElementDragControls.ts | 36 ++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx index 4f1fc11641..1ab7dba11e 100644 --- a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx @@ -1,4 +1,4 @@ -import { motionValue, Variants } from "motion-dom" +import { isDragging, motionValue, Variants } from "motion-dom" import { frame, motion } from "../../" import { pointerDown, @@ -212,6 +212,69 @@ describe("hover", () => { return expect(promise).resolves.toBe(0.9) }) + test("whileHover is unapplied after drag ends when pointer left element during drag", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Simulate drag active state + isDragging.x = true + + // pointerLeave during drag is blocked by isValidHover + pointerLeave(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // End drag + isDragging.x = false + + // Simulate boundary event after pointer capture release + // (browser fires pointerleave when pointer is not over element) + pointerLeave(element) + await nextFrame() + expect(opacity.get()).toBe(1) + }) + + test("whileHover remains active when pointer is over element after drag ends", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Simulate drag active state + isDragging.x = true + + // End drag without pointerLeave (pointer still over element) + isDragging.x = false + + await nextFrame() + // Hover should still be active since pointer never left + expect(opacity.get()).toBe(0.5) + }) + test("whileHover only animates values that aren't being controlled by a higher-priority gesture ", () => { const promise = new Promise(async (resolve) => { const variant = { diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index 53b0284dca..e469370574 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -83,6 +83,12 @@ export class VisualElementDragControls { */ private elastic = createBox() + /** + * The pointer ID captured via setPointerCapture during drag. + * Used to ensure correct hover state after drag ends. + */ + private capturedPointerId: number | undefined = undefined + /** * The latest pointer event. Used as fallback when the `cancel` and `stop` functions are called without arguments. */ @@ -140,6 +146,22 @@ export class VisualElementDragControls { this.latestPanInfo = info this.isDragging = true + // Capture the pointer so the browser suppresses pointerleave during drag. + // On implicit release (pointerup), the browser re-evaluates pointer position + // and fires boundary events, correctly ending whileHover if pointer is + // no longer over the element. + const element = this.visualElement.current + if (element && element.setPointerCapture) { + try { + element.setPointerCapture(event.pointerId) + this.capturedPointerId = event.pointerId + } catch { + // Pointer capture can fail with synthetic events (e.g. in + // test environments) where the browser doesn't track the + // pointerId as an active pointer. + } + } + this.currentDirection = null this.resolveConstraints() @@ -296,6 +318,20 @@ export class VisualElementDragControls { cancel() { this.isDragging = false + // Release pointer capture if held. For pointerup-triggered ends this is + // already released implicitly, but programmatic cancel() calls need + // explicit release. hasPointerCapture guards against double-release. + const element = this.visualElement.current + if ( + element && + this.capturedPointerId !== undefined && + element.hasPointerCapture && + element.hasPointerCapture(this.capturedPointerId) + ) { + element.releasePointerCapture(this.capturedPointerId) + } + this.capturedPointerId = undefined + const { projection, animationState } = this.visualElement if (projection) { From ba85e4cb19aaca8c2fbbd046623310f7ca861e65 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 22:08:36 +0100 Subject: [PATCH 08/35] feat(gestures): Add stopTapPropagation prop to prevent parent tap handlers from firing Fixes #2277. The documented workaround using onPointerDownCapture + stopPropagation is broken in React 17+ because React intercepts native events at the root. This adds a Motion-native mechanism using a WeakSet to track claimed pointerdown events, allowing child elements to prevent ancestor tap gesture handlers without affecting other listeners. Co-Authored-By: Claude Opus 4.5 --- .../src/gestures/__tests__/press.test.tsx | 124 ++++++++++++++++++ packages/framer-motion/src/gestures/press.ts | 7 +- .../src/motion/utils/valid-prop.ts | 1 + .../motion-dom/src/gestures/press/index.ts | 8 ++ packages/motion-dom/src/node/types.ts | 13 ++ 5 files changed, 152 insertions(+), 1 deletion(-) diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 144f394e75..12feefec73 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -767,6 +767,130 @@ describe("press", () => { ]) }) + test("stopTapPropagation prevents parent onTap from firing", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + stopTapPropagation + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + }) + + test("without stopTapPropagation both parent and child onTap fire", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(1) + }) + + test("stopTapPropagation isolates whileTap to child only", () => { + const promise = new Promise(async (resolve) => { + const parentOpacityHistory: number[] = [] + const childOpacityHistory: number[] = [] + const parentOpacity = motionValue(0.5) + const childOpacity = motionValue(0.5) + const logOpacities = () => { + parentOpacityHistory.push(parentOpacity.get()) + childOpacityHistory.push(childOpacity.get()) + } + const Component = () => ( + + + + ) + + const { getByTestId } = render() + await nextFrame() + logOpacities() // both 0.5 + + pointerDown(getByTestId("child")) + await nextFrame() + logOpacities() // child 1, parent 0.5 + + pointerUp(getByTestId("child")) + await nextFrame() + logOpacities() // both 0.5 + + resolve({ parentOpacityHistory, childOpacityHistory }) + }) + + return expect(promise).resolves.toEqual({ + parentOpacityHistory: [0.5, 0.5, 0.5], + childOpacityHistory: [0.5, 1, 0.5], + }) + }) + + test("stopTapPropagation prevents all ancestor onTap handlers (three levels)", async () => { + const grandparentTap = jest.fn() + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + grandparentTap()}> + parentTap()}> + childTap()} + stopTapPropagation + /> + + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + expect(grandparentTap).toBeCalledTimes(0) + }) + test("ignore press event when button is disabled", async () => { const press = jest.fn() const Component = () => press()} disabled /> diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index e5d2650896..79585ae55d 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -32,6 +32,8 @@ export class PressGesture extends Feature { const { current } = this.node if (!current) return + const { globalTapTarget, stopTapPropagation } = this.node.props + this.unmount = press( current, (_element, startEvent) => { @@ -44,7 +46,10 @@ export class PressGesture extends Feature { success ? "End" : "Cancel" ) }, - { useGlobalTarget: this.node.props.globalTapTarget } + { + useGlobalTarget: globalTapTarget, + stopPropagation: stopTapPropagation, + } ) } diff --git a/packages/framer-motion/src/motion/utils/valid-prop.ts b/packages/framer-motion/src/motion/utils/valid-prop.ts index 1c9a8ecf87..0a1875d16b 100644 --- a/packages/framer-motion/src/motion/utils/valid-prop.ts +++ b/packages/framer-motion/src/motion/utils/valid-prop.ts @@ -35,6 +35,7 @@ const validMotionProps = new Set([ "onViewportEnter", "onViewportLeave", "globalTapTarget", + "stopTapPropagation", "ignoreStrict", "viewport", ]) diff --git a/packages/motion-dom/src/gestures/press/index.ts b/packages/motion-dom/src/gestures/press/index.ts index ecc6f9805b..d82bc3565d 100644 --- a/packages/motion-dom/src/gestures/press/index.ts +++ b/packages/motion-dom/src/gestures/press/index.ts @@ -18,8 +18,11 @@ function isValidPressEvent(event: PointerEvent) { return isPrimaryPointer(event) && !isDragActive() } +const claimedPointerDownEvents = new WeakSet() + export interface PointerEventOptions extends EventOptions { useGlobalTarget?: boolean + stopPropagation?: boolean } /** @@ -55,9 +58,14 @@ export function press( const target = startEvent.currentTarget as Element if (!isValidPressEvent(startEvent)) return + if (claimedPointerDownEvents.has(startEvent)) return isPressing.add(target) + if (options.stopPropagation) { + claimedPointerDownEvents.add(startEvent) + } + const onPressEnd = onPressStart(target, startEvent) const onPointerEnd = (endEvent: PointerEvent, success: boolean) => { diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index d5aa01b977..7923acb3bd 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -538,6 +538,19 @@ export interface MotionNodeTapHandlers { * Note: This is not supported publically. */ globalTapTarget?: boolean + + /** + * If `true`, this element's tap gesture will prevent any parent + * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) + * from firing. + * + * ```jsx + * + * + * + * ``` + */ + stopTapPropagation?: boolean } /** From f3404e51dc27b226528a917bdf0fa5e0727f2eb5 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 10:21:45 +0100 Subject: [PATCH 09/35] test: Add Cypress E2E test for Suspense animation resume Add a Cypress test and dev app test page that verify motion values are reset to initial after a Suspense unmount/remount cycle. The test runs against both React 18 (port 9990) and React 19 (port 9991). Co-Authored-By: Claude Opus 4.5 --- .../src/tests/suspense-animation-resume.tsx | 62 +++++++++++++++++++ .../integration/suspense-animation-resume.ts | 39 ++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 dev/react/src/tests/suspense-animation-resume.tsx create mode 100644 packages/framer-motion/cypress/integration/suspense-animation-resume.ts diff --git a/dev/react/src/tests/suspense-animation-resume.tsx b/dev/react/src/tests/suspense-animation-resume.tsx new file mode 100644 index 0000000000..382ac38da4 --- /dev/null +++ b/dev/react/src/tests/suspense-animation-resume.tsx @@ -0,0 +1,62 @@ +import { Suspense, useEffect, useRef, useState } from "react" +import { motion } from "framer-motion" + +/** + * Test component that verifies motion values are reset to initial values + * after a Suspense unmount/remount cycle (issue #2269). + * + * Timeline: + * 0ms - Animation starts (opacity 0 → 1, scale 0.5 → 2) + * 400ms - Component suspends mid-animation + * 900ms - Component resumes, values should reset to initial + */ +const SuspendingChild = () => { + const [promise, setPromise] = useState>(null) + const hasSuspended = useRef(false) + + useEffect(() => { + if (hasSuspended.current) return + + const suspendTimeout = setTimeout(() => { + hasSuspended.current = true + setPromise( + new Promise((resolve) => { + setTimeout(() => { + resolve() + setPromise(null) + }, 500) + }) + ) + }, 400) + + return () => { + clearTimeout(suspendTimeout) + } + }, []) + + if (promise) { + throw promise + } + + return ( + + ) +} + +export function App() { + return ( + Suspended}> + + + ) +} diff --git a/packages/framer-motion/cypress/integration/suspense-animation-resume.ts b/packages/framer-motion/cypress/integration/suspense-animation-resume.ts new file mode 100644 index 0000000000..85e394904d --- /dev/null +++ b/packages/framer-motion/cypress/integration/suspense-animation-resume.ts @@ -0,0 +1,39 @@ +/** + * Test that motion values are reset to initial values after a React Suspense + * unmount/remount cycle. Verifies the fix for issue #2269. + * + * Without the fix, scale gets stuck at an intermediate animation value + * and opacity appears incorrectly reset after Suspense remount. + * + * Timeline of the test component: + * 0ms - Animation starts (opacity 0 → 1, scale 0.5 → 2, duration 10s) + * 400ms - Component suspends + * 900ms - Component resumes, values should reset to initial + */ +describe("Animation resume after Suspense", () => { + it("resets values to initial after Suspense remount", () => { + cy.visit("?test=suspense-animation-resume") + .wait(50) + // Element should exist and be animating + .get("#target") + .should("exist") + + // Wait for suspend — fallback should appear + .get("#fallback", { timeout: 2000 }) + .should("exist") + .should("contain", "Suspended") + + // Wait for resume — target should reappear + .get("#target", { timeout: 2000 }) + .should("exist") + .should(([$element]: any) => { + // Right after remount, opacity should be reset to initial (0), + // not stuck at an intermediate value from before suspension. + // Use getComputedStyle since the inline style may not be set yet. + const opacity = parseFloat( + window.getComputedStyle($element).opacity + ) + expect(opacity).to.be.lessThan(0.3) + }) + }) +}) From 6f072c5038b794061ec2faea0bfd60a53c2edb49 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 10:05:34 +0100 Subject: [PATCH 10/35] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf5e7f851..ab26794425 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.31.2] 2026-02-05 + +### Fixed + +- `onHoverStart` and `onHoverEnd` first argument now correctly typed as `PointerEvent`. + ## [12.31.1] 2026-02-04 ### Added From 158ed7697c9c2fd316fd05fff3cc9a46bbc1aeb9 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 10:54:15 +0100 Subject: [PATCH 11/35] refactor: Remove redundant Object.keys check in mount() The for..in loop is a no-op when initialValues is empty, so the length check is unnecessary. Co-Authored-By: Claude Opus 4.5 --- packages/motion-dom/src/render/VisualElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index 19cdf58d70..375a348eef 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -439,7 +439,7 @@ export abstract class VisualElement< * unmount/remount), reset motion values to their initial state * so animations replay correctly from initial → animate. */ - if (this.hasBeenMounted && Object.keys(this.initialValues).length) { + if (this.hasBeenMounted) { for (const key in this.initialValues) { const value = this.values.get(key) if (value) { From 60da7771935a740624d5a546ef26db67b766404e Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 10:54:33 +0100 Subject: [PATCH 12/35] refactor: Use optional chaining for value.jump() Co-Authored-By: Claude Opus 4.5 --- packages/motion-dom/src/render/VisualElement.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index 375a348eef..9d27835627 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -441,10 +441,7 @@ export abstract class VisualElement< */ if (this.hasBeenMounted) { for (const key in this.initialValues) { - const value = this.values.get(key) - if (value) { - value.jump(this.initialValues[key]) - } + this.values.get(key)?.jump(this.initialValues[key]) this.latestValues[key] = this.initialValues[key] } } From 95642b2d632006908797cf100d89552b0f89a6d8 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 10:58:01 +0100 Subject: [PATCH 13/35] feat(transition): Replace inheritTransition prop with transition.inherit Move transition merging from a MotionConfig prop to a field on the transition object itself. This enables merging at two levels: MotionConfig nesting and value-specific overrides (e.g. opacity). Co-Authored-By: Claude Opus 4.5 --- .../__tests__/get-value-transition.test.ts | 67 +++++++++++++++++++ .../__tests__/MotionConfig.test.tsx | 33 ++++++--- .../src/components/MotionConfig/index.tsx | 6 +- .../src/context/MotionConfigContext.tsx | 7 -- packages/motion-dom/src/animation/types.ts | 8 +++ .../animation/utils/get-value-transition.ts | 10 ++- 6 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 packages/framer-motion/src/animation/__tests__/get-value-transition.test.ts diff --git a/packages/framer-motion/src/animation/__tests__/get-value-transition.test.ts b/packages/framer-motion/src/animation/__tests__/get-value-transition.test.ts new file mode 100644 index 0000000000..0365f44a3c --- /dev/null +++ b/packages/framer-motion/src/animation/__tests__/get-value-transition.test.ts @@ -0,0 +1,67 @@ +import { getValueTransition } from "motion-dom" + +describe("getValueTransition", () => { + it("returns value-specific transition as-is without inherit", () => { + const transition = { + duration: 1, + opacity: { duration: 2 }, + } + const result = getValueTransition(transition, "opacity") + expect(result).toEqual({ duration: 2 }) + }) + + it("returns base transition when no value-specific key exists", () => { + const transition = { duration: 1, ease: "easeIn" } + const result = getValueTransition(transition, "opacity") + expect(result).toEqual({ duration: 1, ease: "easeIn" }) + }) + + it("falls back to default key", () => { + const transition = { + duration: 1, + default: { duration: 3 }, + } + const result = getValueTransition(transition, "opacity") + expect(result).toEqual({ duration: 3 }) + }) + + it("merges value-specific with base transition when inherit is true", () => { + const transition = { + duration: 1, + ease: "easeIn" as const, + opacity: { inherit: true, duration: 2 }, + } + const result = getValueTransition(transition, "opacity") + expect(result.duration).toBe(2) + expect(result.ease).toBe("easeIn") + }) + + it("strips inherit key from merged result", () => { + const transition = { + duration: 1, + opacity: { inherit: true, duration: 2 }, + } + const result = getValueTransition(transition, "opacity") + expect(result).not.toHaveProperty("inherit") + }) + + it("inner keys win when merging with inherit", () => { + const transition = { + duration: 1, + ease: "easeIn" as const, + opacity: { inherit: true, duration: 2, ease: "easeOut" as const }, + } + const result = getValueTransition(transition, "opacity") + expect(result.duration).toBe(2) + expect(result.ease).toBe("easeOut") + }) + + it("does not merge when inherit is on the base transition itself (fallback case)", () => { + const transition = { + inherit: true, + duration: 1, + } + const result = getValueTransition(transition, "opacity") + expect(result).toBe(transition) + }) +}) diff --git a/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx b/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx index bfb0d23f4d..1a351f0f47 100644 --- a/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx +++ b/packages/framer-motion/src/components/MotionConfig/__tests__/MotionConfig.test.tsx @@ -41,7 +41,7 @@ it("Passes down transition changes", () => { expect(getByTestId(consumerId).textContent).toBe("tween") }) -it("Nested MotionConfig without inheritTransition fully replaces parent transition", () => { +it("Nested MotionConfig without inherit fully replaces parent transition", () => { const { getByTestId } = render( @@ -58,10 +58,10 @@ it("Nested MotionConfig without inheritTransition fully replaces parent transiti expect(transition.duration).toBeUndefined() }) -it("Nested MotionConfig with inheritTransition shallow-merges with parent transition", () => { +it("Nested MotionConfig with inherit shallow-merges with parent transition", () => { const { getByTestId } = render( - + @@ -75,12 +75,26 @@ it("Nested MotionConfig with inheritTransition shallow-merges with parent transi expect(transition.delay).toBe(0.5) }) -it("inheritTransition inner keys win over parent keys", () => { +it("inherit key is stripped from resulting transition", () => { + const { getByTestId } = render( + + + + + + ) + + const transition: Transition = JSON.parse( + getByTestId(consumerId).textContent! + ) + expect(transition).not.toHaveProperty("inherit") +}) + +it("inherit inner keys win over parent keys", () => { const { getByTestId } = render( @@ -95,13 +109,12 @@ it("inheritTransition inner keys win over parent keys", () => { expect(transition.delay).toBe(0.5) }) -it("inheritTransition cascades through deeply nested MotionConfigs", () => { +it("inherit cascades through deeply nested MotionConfigs", () => { const { getByTestId } = render( - + diff --git a/packages/framer-motion/src/components/MotionConfig/index.tsx b/packages/framer-motion/src/components/MotionConfig/index.tsx index 29ae5ea21a..1135c70863 100644 --- a/packages/framer-motion/src/components/MotionConfig/index.tsx +++ b/packages/framer-motion/src/components/MotionConfig/index.tsx @@ -44,8 +44,9 @@ export function MotionConfig({ const parentConfig = useContext(MotionConfigContext) config = { ...parentConfig, ...config } - if (config.inheritTransition) { - config.transition = { ...parentConfig.transition, ...config.transition } + if (config.transition?.inherit) { + const { inherit: _, ...childTransition } = config.transition + config.transition = { ...parentConfig.transition, ...childTransition } } /** @@ -65,7 +66,6 @@ export function MotionConfig({ config.transformPagePoint, config.reducedMotion, config.skipAnimations, - config.inheritTransition, ] ) diff --git a/packages/framer-motion/src/context/MotionConfigContext.tsx b/packages/framer-motion/src/context/MotionConfigContext.tsx index 0c47dd326d..2aa7652d9e 100644 --- a/packages/framer-motion/src/context/MotionConfigContext.tsx +++ b/packages/framer-motion/src/context/MotionConfigContext.tsx @@ -53,13 +53,6 @@ export interface MotionConfigContext { */ skipAnimations?: boolean - /** - * If true, this MotionConfig's transition will be shallow-merged with the - * parent MotionConfig's transition instead of replacing it. - * - * @public - */ - inheritTransition?: boolean } /** diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index e32b2ad066..23d4040dae 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -468,6 +468,14 @@ export interface ValueTransition // @deprecated from?: any + + /** + * If true, this transition will shallow-merge with its parent transition + * instead of replacing it. Inner keys win. + * + * @public + */ + inherit?: boolean } /** diff --git a/packages/motion-dom/src/animation/utils/get-value-transition.ts b/packages/motion-dom/src/animation/utils/get-value-transition.ts index e2381f9e2b..0f2081e399 100644 --- a/packages/motion-dom/src/animation/utils/get-value-transition.ts +++ b/packages/motion-dom/src/animation/utils/get-value-transition.ts @@ -1,7 +1,13 @@ export function getValueTransition(transition: any, key: string) { - return ( + const valueTransition = transition?.[key as keyof typeof transition] ?? transition?.["default"] ?? transition - ) + + if (valueTransition?.inherit && valueTransition !== transition) { + const { inherit: _, ...rest } = valueTransition + return { ...transition, ...rest } + } + + return valueTransition } From a2429249a5de55ed25ad4a284929f6001d110e21 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 10:58:02 +0100 Subject: [PATCH 14/35] docs: Add file size and optional chaining guidance to CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 744954ab64..6eede4bf4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,8 @@ async function nextFrame() { ## Code Style +- **Prioritise small file size** — this is a library shipped to end users. Prefer concise patterns that minimise output bytes. +- Prefer optional chaining (`value?.jump()`) over explicit `if` statements - Use `interface` for type definitions (enforced by ESLint) - No default exports (use named exports) - Prefer arrow callbacks From 4830aba029574793701bad92a0c5211ddf50f55e Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 11:18:20 +0100 Subject: [PATCH 15/35] fix(hover): Defer pointerleave during press to fix whileHover dropping before drag starts The previous pointer capture approach in the drag system didn't cover the window between pointerdown and drag start. Move the protection into hover() itself by tracking press state per-element and deferring pointerleave processing while the pointer is pressed. Co-Authored-By: Claude Opus 4.5 --- .../src/gestures/__tests__/hover.test.tsx | 84 ++++++++++++++-- .../drag/VisualElementDragControls.ts | 36 ------- packages/motion-dom/src/gestures/hover.ts | 96 +++++++++++++++---- 3 files changed, 155 insertions(+), 61 deletions(-) diff --git a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx index 1ab7dba11e..8c83d2e2a2 100644 --- a/packages/framer-motion/src/gestures/__tests__/hover.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/hover.test.tsx @@ -4,6 +4,7 @@ import { pointerDown, pointerEnter, pointerLeave, + pointerUp, render, } from "../../jest.setup" import { nextFrame } from "./utils" @@ -229,20 +230,18 @@ describe("hover", () => { await nextFrame() expect(opacity.get()).toBe(0.5) - // Simulate drag active state + // Press and start drag + pointerDown(element) isDragging.x = true - // pointerLeave during drag is blocked by isValidHover + // pointerLeave during drag is deferred pointerLeave(element) await nextFrame() expect(opacity.get()).toBe(0.5) - // End drag + // End drag, then release pointer isDragging.x = false - - // Simulate boundary event after pointer capture release - // (browser fires pointerleave when pointer is not over element) - pointerLeave(element) + pointerUp(element) await nextFrame() expect(opacity.get()).toBe(1) }) @@ -264,17 +263,86 @@ describe("hover", () => { await nextFrame() expect(opacity.get()).toBe(0.5) - // Simulate drag active state + // Press and start drag + pointerDown(element) isDragging.x = true // End drag without pointerLeave (pointer still over element) isDragging.x = false + pointerUp(element) await nextFrame() // Hover should still be active since pointer never left expect(opacity.get()).toBe(0.5) }) + test("whileHover stays active during press and deactivates on release outside element", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Press down + pointerDown(element) + + // Pointer leaves while pressed (no drag involved) + pointerLeave(element) + await nextFrame() + // Hover should stay active because pointer is still pressed + expect(opacity.get()).toBe(0.5) + + // Release pointer (outside element) + pointerUp(element) + await nextFrame() + // Now hover should deactivate + expect(opacity.get()).toBe(1) + }) + + test("whileHover stays active during press when pointer leaves before drag starts", async () => { + const opacity = motionValue(1) + const Component = () => ( + + ) + + const { container } = render() + const element = container.firstChild as Element + + pointerEnter(element) + await nextFrame() + expect(opacity.get()).toBe(0.5) + + // Press down (drag hasn't started yet — needs movement threshold) + pointerDown(element) + + // Pointer leaves before drag starts + pointerLeave(element) + await nextFrame() + // Hover should stay active because pointer is pressed + expect(opacity.get()).toBe(0.5) + + // Release pointer + pointerUp(element) + await nextFrame() + // Now hover should deactivate + expect(opacity.get()).toBe(1) + }) + test("whileHover only animates values that aren't being controlled by a higher-priority gesture ", () => { const promise = new Promise(async (resolve) => { const variant = { diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index e469370574..53b0284dca 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -83,12 +83,6 @@ export class VisualElementDragControls { */ private elastic = createBox() - /** - * The pointer ID captured via setPointerCapture during drag. - * Used to ensure correct hover state after drag ends. - */ - private capturedPointerId: number | undefined = undefined - /** * The latest pointer event. Used as fallback when the `cancel` and `stop` functions are called without arguments. */ @@ -146,22 +140,6 @@ export class VisualElementDragControls { this.latestPanInfo = info this.isDragging = true - // Capture the pointer so the browser suppresses pointerleave during drag. - // On implicit release (pointerup), the browser re-evaluates pointer position - // and fires boundary events, correctly ending whileHover if pointer is - // no longer over the element. - const element = this.visualElement.current - if (element && element.setPointerCapture) { - try { - element.setPointerCapture(event.pointerId) - this.capturedPointerId = event.pointerId - } catch { - // Pointer capture can fail with synthetic events (e.g. in - // test environments) where the browser doesn't track the - // pointerId as an active pointer. - } - } - this.currentDirection = null this.resolveConstraints() @@ -318,20 +296,6 @@ export class VisualElementDragControls { cancel() { this.isDragging = false - // Release pointer capture if held. For pointerup-triggered ends this is - // already released implicitly, but programmatic cancel() calls need - // explicit release. hasPointerCapture guards against double-release. - const element = this.visualElement.current - if ( - element && - this.capturedPointerId !== undefined && - element.hasPointerCapture && - element.hasPointerCapture(this.capturedPointerId) - ) { - element.releasePointerCapture(this.capturedPointerId) - } - this.capturedPointerId = undefined - const { projection, animationState } = this.visualElement if (projection) { diff --git a/packages/motion-dom/src/gestures/hover.ts b/packages/motion-dom/src/gestures/hover.ts index 32d2240c68..ebaeead7fe 100644 --- a/packages/motion-dom/src/gestures/hover.ts +++ b/packages/motion-dom/src/gestures/hover.ts @@ -44,37 +44,99 @@ export function hover( options ) - const onPointerEnter = (enterEvent: PointerEvent) => { - if (!isValidHover(enterEvent)) return + elements.forEach((element) => { + let isPressed = false + let deferredHoverEnd = false + let hoverEndCallback: OnHoverEndEvent | undefined + + const removePointerLeave = () => { + element.removeEventListener( + "pointerleave", + onPointerLeave as EventListener + ) + } + + const endHover = (event: PointerEvent) => { + if (hoverEndCallback) { + hoverEndCallback(event) + hoverEndCallback = undefined + } + removePointerLeave() + } - const { target } = enterEvent - const onHoverEnd = onHoverStart(target as Element, enterEvent) + const onPointerUp = (event: Event) => { + isPressed = false + window.removeEventListener( + "pointerup", + onPointerUp as EventListener + ) + window.removeEventListener( + "pointercancel", + onPointerUp as EventListener + ) + + if (deferredHoverEnd) { + deferredHoverEnd = false + endHover(event as PointerEvent) + } + } - if (typeof onHoverEnd !== "function" || !target) return + const onPointerDown = () => { + isPressed = true + window.addEventListener( + "pointerup", + onPointerUp as EventListener, + eventOptions + ) + window.addEventListener( + "pointercancel", + onPointerUp as EventListener, + eventOptions + ) + } const onPointerLeave = (leaveEvent: PointerEvent) => { - if (!isValidHover(leaveEvent)) return + if (leaveEvent.pointerType === "touch") return + + if (isPressed) { + deferredHoverEnd = true + return + } - onHoverEnd(leaveEvent) - target.removeEventListener( + endHover(leaveEvent) + } + + const onPointerEnter = (enterEvent: PointerEvent) => { + if (!isValidHover(enterEvent)) return + + deferredHoverEnd = false + + const onHoverEnd = onHoverStart( + element as Element, + enterEvent + ) + + if (typeof onHoverEnd !== "function") return + + hoverEndCallback = onHoverEnd + + element.addEventListener( "pointerleave", - onPointerLeave as EventListener + onPointerLeave as EventListener, + eventOptions ) } - target.addEventListener( - "pointerleave", - onPointerLeave as EventListener, - eventOptions - ) - } - - elements.forEach((element) => { element.addEventListener( "pointerenter", onPointerEnter as EventListener, eventOptions ) + element.addEventListener( + "pointerdown", + onPointerDown as EventListener, + eventOptions + ) }) return cancel From a18894c0b15829498aa9de920b4deed348a5a3f6 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 11:44:14 +0100 Subject: [PATCH 16/35] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab26794425..4149d0a280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Undocumented APIs should be considered internal and may change without warning. ### Fixed - `onHoverStart` and `onHoverEnd` first argument now correctly typed as `PointerEvent`. +- `whileHover`: No longer persists after drag end. +- `AnimatePresence`: Allow changing `mode` prop. ## [12.31.1] 2026-02-04 From 09fd2ea35f438749cd9264324dfe08b9c304342f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 11:44:42 +0100 Subject: [PATCH 17/35] v12.31.2 --- 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 80376f9be9..0e5eb81ed6 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.31.1", + "version": "12.31.2", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.31.1", - "motion": "^12.31.1", - "motion-dom": "^12.30.1", + "framer-motion": "^12.31.2", + "motion": "^12.31.2", + "motion-dom": "^12.31.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index fe8974c42b..fc4379b485 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.31.1", + "version": "12.31.2", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.31.1", + "motion": "^12.31.2", "next": "15.4.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 3848ec2ad5..eb2e4e293b 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.31.1", + "version": "12.31.2", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.31.1", + "motion": "^12.31.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 9689faee8a..de40cd2172 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.31.1", + "version": "12.31.2", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.31.1", + "framer-motion": "^12.31.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index c5e22ac709..e0c7913f05 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.31.1", + "version": "12.31.2", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index c4394f1d10..6800be4c45 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.31.1", + "version": "12.31.2", "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.30.1", + "motion-dom": "^12.31.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 429f92a9b2..f853bb9678 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.30.1", + "version": "12.31.2", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 8ec9a4c206..a64cb6ab4a 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.31.1", + "version": "12.31.2", "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.31.1", + "framer-motion": "^12.31.2", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 0c72a6a06f..197b61dcf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.31.1, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.31.2, 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.30.1 + motion-dom: ^12.31.2 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8192,9 +8192,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.31.1 - motion: ^12.31.1 - motion-dom: ^12.30.1 + framer-motion: ^12.31.2 + motion: ^12.31.2 + motion-dom: ^12.31.2 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.30.1, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.31.2, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.31.1, motion@workspace:packages/motion": +"motion@^12.31.2, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.31.1 + framer-motion: ^12.31.2 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.31.1 + motion: ^12.31.2 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.31.1 + motion: ^12.31.2 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,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.31.1 + framer-motion: ^12.31.2 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 5d1fd4e42cfe9cd09b109ffe1f1cec17015bbc0a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 12:55:42 +0100 Subject: [PATCH 18/35] Trying xlarge resource class --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 246405bf55..50dad4810a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,7 +41,7 @@ jobs: - image: cimg/node:20.11.1-browsers working_directory: ~/repo parallelism: 6 - resource_class: large + resource_class: xlarge steps: - checkout @@ -78,7 +78,7 @@ jobs: - image: cimg/node:20.11.1-browsers working_directory: ~/repo parallelism: 6 - resource_class: large + resource_class: xlarge steps: - checkout From 7b5773014cb98246b212d7a708dec24c9f9f4134 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 12:56:40 +0100 Subject: [PATCH 19/35] Reverting to large resource class --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 50dad4810a..6f49e52451 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -78,7 +78,7 @@ jobs: - image: cimg/node:20.11.1-browsers working_directory: ~/repo parallelism: 6 - resource_class: xlarge + resource_class: large steps: - checkout From 719e5524d894621a6b1505407a66437e8045c7f8 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 13:01:48 +0100 Subject: [PATCH 20/35] Updating circleci workflow --- .circleci/config.yml | 152 ++++++++++++------------------------------- 1 file changed, 42 insertions(+), 110 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6f49e52451..e3c2278c12 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ # Check https://circleci.com/docs/2.0/language-javascript/ for more details # jobs: - test: + setup: docker: - image: cimg/node:20.11.1-browsers working_directory: ~/repo @@ -9,11 +9,6 @@ jobs: steps: - checkout - - run: - name: Yarn version - command: yarn --version - - # Download and cache dependencies (Yarn artifacts, not node_modules) - restore_cache: keys: - v3-yarn-{{ checksum "yarn.lock" }} @@ -29,6 +24,24 @@ jobs: - .yarn/patches key: v3-yarn-{{ checksum "yarn.lock" }} + - run: + name: Build + command: yarn build + + - persist_to_workspace: + root: . + paths: + - . + + test: + docker: + - image: cimg/node:20.11.1-browsers + working_directory: ~/repo + resource_class: large + steps: + - attach_workspace: + at: ~/repo + - run: name: Test Jest command: make test-jest @@ -40,29 +53,11 @@ jobs: docker: - image: cimg/node:20.11.1-browsers working_directory: ~/repo - parallelism: 6 - resource_class: xlarge + parallelism: 4 + resource_class: large steps: - - checkout - - - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} + - attach_workspace: + at: ~/repo - run: name: React tests @@ -77,32 +72,14 @@ jobs: docker: - image: cimg/node:20.11.1-browsers working_directory: ~/repo - parallelism: 6 + parallelism: 4 resource_class: large steps: - - checkout + - attach_workspace: + at: ~/repo - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} - - - run: - name: React tests + name: React 19 tests command: make test-react-19 environment: JEST_JUNIT_OUTPUT: test_reports/framer-motion-react-19.xml @@ -116,26 +93,8 @@ jobs: resource_class: large working_directory: ~/repo steps: - - checkout - - - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} + - attach_workspace: + at: ~/repo - run: name: HTML tests @@ -152,47 +111,20 @@ jobs: - store_test_results: path: test_reports - test-playwright: - docker: - - image: mcr.microsoft.com/playwright:v1.51.1-noble - resource_class: large - working_directory: ~/repo - steps: - - checkout - - - run: - name: Yarn version - command: yarn --version - - - restore_cache: - keys: - - v3-yarn-{{ checksum "yarn.lock" }} - - v3-yarn- - - run: - name: Install dependencies (immutable) - command: yarn install --immutable - - save_cache: - paths: - - .yarn/cache - - .yarn/releases - - .yarn/plugins - - .yarn/patches - key: v3-yarn-{{ checksum "yarn.lock" }} - - - run: - name: Playwright tests - command: yarn test-playwright - environment: - JEST_JUNIT_OUTPUT: test_reports/framer-motion-playwright.xml - - - store_test_results: - path: test_reports - workflows: version: 2 build: jobs: - - test - - test-react - - test-react-19 - - test-html + - setup + - test: + requires: + - setup + - test-react: + requires: + - setup + - test-react-19: + requires: + - setup + - test-html: + requires: + - setup From 2025d924ff7a0edda2cef7a24fc9177d29e21e5f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 13:14:53 +0100 Subject: [PATCH 21/35] Updating workflow --- .circleci/config.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index e3c2278c12..fbc00c5ae0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,6 +6,8 @@ jobs: - image: cimg/node:20.11.1-browsers working_directory: ~/repo resource_class: large + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - checkout @@ -55,6 +57,8 @@ jobs: working_directory: ~/repo parallelism: 4 resource_class: large + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - attach_workspace: at: ~/repo @@ -74,6 +78,8 @@ jobs: working_directory: ~/repo parallelism: 4 resource_class: large + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - attach_workspace: at: ~/repo @@ -92,6 +98,8 @@ jobs: - image: cimg/node:20.11.1-browsers resource_class: large working_directory: ~/repo + environment: + CYPRESS_CACHE_FOLDER: ~/repo/.cache/Cypress steps: - attach_workspace: at: ~/repo From c429439c5ab2f8d8f09bf251bf13cd697e49cf9a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 4 Feb 2026 14:55:06 +0100 Subject: [PATCH 22/35] fix: transitionEnd stuck with stale values after rapid variant switching When all animations are instant (shouldSkip), Promise.all([]) resolves as a microtask, deferring the transitionEnd frame.update callback until after the new variant's callbacks are already queued. The stale transitionEnd then fires last, overriding the new variant's values. Fix: when animations array is empty, queue transitionEnd synchronously via frame.update instead of through Promise.all, so it's ordered correctly in the same frame batch as the animation callbacks. Fixes #1668. Co-Authored-By: Claude Opus 4.5 --- .../src/motion/__tests__/variant.test.tsx | 57 +++++++++++++++++++ .../interfaces/visual-element-target.ts | 9 ++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/framer-motion/src/motion/__tests__/variant.test.tsx b/packages/framer-motion/src/motion/__tests__/variant.test.tsx index 8c454f3848..efe019db9a 100644 --- a/packages/framer-motion/src/motion/__tests__/variant.test.tsx +++ b/packages/framer-motion/src/motion/__tests__/variant.test.tsx @@ -1380,6 +1380,63 @@ describe("animate prop as variant", () => { expect(element).toHaveStyle("transform: translateX(100px)") }) + test("transitionEnd from instant animation does not override subsequent variant", async () => { + /** + * This test targets the race condition from + * https://github.com/motiondivision/motion/issues/1668 + * + * When using type: false (instant transitions), the animation + * completion is deferred to frame.update() but returns no + * animation object. When a new variant switch happens before + * that frame.update fires, the old transitionEnd can override + * the new variant's values because there's no animation object + * to cancel. + */ + const Component = ({ variant }: { variant: string }) => ( + + ) + + const { getByTestId, rerender } = render( + + ) + const element = getByTestId("target") + + await nextFrame() + + // Switch to "on" - with type:false, animation completes instantly + // but onComplete is deferred to frame.update + rerender() + rerender() + + // Switch to "off" BEFORE the frame fires - the "on" variant's + // transitionEnd (display: "flex") should NOT override "off"'s + // display: "none" + rerender() + rerender() + + await nextFrame() + await nextFrame() + + expect(element).toHaveStyle("display: none") + }) + test("staggerChildren is calculated correctly for new children", async () => { const Component = ({ items }: { items: string[] }) => { return ( diff --git a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts index d69a647514..48b86cd80a 100644 --- a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts +++ b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts @@ -132,11 +132,16 @@ export function animateTarget( } if (transitionEnd) { - Promise.all(animations).then(() => { + const applyTransitionEnd = () => frame.update(() => { transitionEnd && setTarget(visualElement, transitionEnd) }) - }) + + if (animations.length) { + Promise.all(animations).then(applyTransitionEnd) + } else { + applyTransitionEnd() + } } return animations From 67a484b46254e960072f41b380eb2dbb407363fb Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 14:02:44 +0100 Subject: [PATCH 23/35] fix(drag): Update test fixture to tall vertical element with vertical drags Co-Authored-By: Claude Opus 4.5 --- Makefile | 2 +- dev/react/src/tests/drag-momentum.tsx | 2 +- .../cypress/integration/drag-momentum.ts | 41 ++++++++++--------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index ad1c34dc0a..9305f08cc7 100644 --- a/Makefile +++ b/Makefile @@ -95,7 +95,7 @@ test-e2e: test-nextjs test-html test-react test-react-19 yarn test-playwright test-single: build test-mkdir - yarn start-server-and-test "yarn dev-server" http://localhost:9991 "cd packages/framer-motion && cypress run --config-file=cypress.react.json --headed --spec cypress/integration/drag-nested.ts" + yarn start-server-and-test "yarn dev-server" http://localhost:9990 "cd packages/framer-motion && cypress run --config-file=cypress.json --headed --spec cypress/integration/drag-momentum.ts" lint: bootstrap yarn lint diff --git a/dev/react/src/tests/drag-momentum.tsx b/dev/react/src/tests/drag-momentum.tsx index 285ff48af5..c99e43f2a3 100644 --- a/dev/react/src/tests/drag-momentum.tsx +++ b/dev/react/src/tests/drag-momentum.tsx @@ -10,7 +10,7 @@ export const App = () => { dragMomentum={true} initial={{ width: 50, - height: 50, + height: 1000, background: "red", x: 0, y: 0, diff --git a/packages/framer-motion/cypress/integration/drag-momentum.ts b/packages/framer-motion/cypress/integration/drag-momentum.ts index d5b95d1842..d3e3e31a8f 100644 --- a/packages/framer-motion/cypress/integration/drag-momentum.ts +++ b/packages/framer-motion/cypress/integration/drag-momentum.ts @@ -3,22 +3,23 @@ describe("Drag Momentum", () => { cy.visit("?test=drag-momentum") .wait(200) .get("[data-testid='draggable']") - .trigger("pointerdown", 25, 25) + .wait(200) + .trigger("pointerdown", 25, 900) .wait(300) // Simulate holding before flick - .trigger("pointermove", 30, 30) // Cross distance threshold + .trigger("pointermove", 25, 895) // Cross distance threshold + .wait(50) + .trigger("pointermove", 25, 800) // Quick flick upward .wait(50) - .trigger("pointermove", 100, 25, { force: true }) // Quick flick - .wait(16) .trigger("pointerup", { force: true }) .wait(500) // Wait for momentum to carry element .should(($draggable: any) => { const draggable = $draggable[0] as HTMLDivElement - const { left } = draggable.getBoundingClientRect() + const { top } = draggable.getBoundingClientRect() // Element should have carried well past the release point // due to momentum. Without the fix, velocity is diluted by // the stale pointer-down point and momentum is minimal. - expect(left).to.be.greaterThan(300) + expect(top).to.be.lessThan(-200) }) }) @@ -26,37 +27,37 @@ describe("Drag Momentum", () => { cy.visit("?test=drag-momentum") .wait(200) .get("[data-testid='draggable']") - // Perform a drag-and-throw - .trigger("pointerdown", 25, 25) - .trigger("pointermove", 30, 30) // Cross distance threshold + .wait(200) + // Perform a drag-and-throw upward + .trigger("pointerdown", 25, 900) + .trigger("pointermove", 25, 895) // Cross distance threshold + .wait(50) + .trigger("pointermove", 25, 700) .wait(50) - .trigger("pointermove", 200, 25, { force: true }) - .wait(16) .trigger("pointerup", { force: true }) - // Wait briefly for momentum to start + // Wait for momentum to start .wait(100) // Record position, then catch and release .then(($draggable: any) => { const draggable = $draggable[0] as HTMLDivElement - const { left } = draggable.getBoundingClientRect() - // Store position for later comparison - $draggable.attr("data-caught-left", Math.round(left)) + const { top } = draggable.getBoundingClientRect() + $draggable.attr("data-caught-top", Math.round(top)) }) - .trigger("pointerdown", 25, 25, { force: true }) + .trigger("pointerdown", 25, 500, { force: true }) .wait(50) .trigger("pointerup", { force: true }) .wait(500) // Wait to see if element continues moving .should(($draggable: any) => { const draggable = $draggable[0] as HTMLDivElement - const { left } = draggable.getBoundingClientRect() - const caughtLeft = parseInt( - $draggable.attr("data-caught-left"), + const { top } = draggable.getBoundingClientRect() + const caughtTop = parseInt( + $draggable.attr("data-caught-top"), 10 ) // Element should stay near where it was caught, // not continue with old momentum. - expect(Math.abs(left - caughtLeft)).to.be.lessThan(50) + expect(Math.abs(top - caughtTop)).to.be.lessThan(50) }) }) }) From 4380dbfce7262ba5798ed405b3838c68d58e440b Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 14:15:41 +0100 Subject: [PATCH 24/35] refactor(transition): Extract resolveTransition and support inherit in animate Extract shared resolveTransition function that handles inherit merging, and reuse it in MotionConfig, getValueTransition, and animateTarget. This enables animate.transition to merge with the component/context transition via inherit: true. Co-Authored-By: Claude Opus 4.5 --- .../src/components/MotionConfig/index.tsx | 9 +++++---- .../interfaces/visual-element-target.ts | 8 +++++++- .../src/animation/utils/get-value-transition.ts | 7 ++++--- .../src/animation/utils/resolve-transition.ts | 16 ++++++++++++++++ packages/motion-dom/src/index.ts | 1 + 5 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 packages/motion-dom/src/animation/utils/resolve-transition.ts diff --git a/packages/framer-motion/src/components/MotionConfig/index.tsx b/packages/framer-motion/src/components/MotionConfig/index.tsx index 1135c70863..82559d2809 100644 --- a/packages/framer-motion/src/components/MotionConfig/index.tsx +++ b/packages/framer-motion/src/components/MotionConfig/index.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { useContext, useMemo } from "react" +import { resolveTransition } from "motion-dom" import { MotionConfigContext } from "../../context/MotionConfigContext" import { loadExternalIsValidProp, @@ -44,10 +45,10 @@ export function MotionConfig({ const parentConfig = useContext(MotionConfigContext) config = { ...parentConfig, ...config } - if (config.transition?.inherit) { - const { inherit: _, ...childTransition } = config.transition - config.transition = { ...parentConfig.transition, ...childTransition } - } + config.transition = resolveTransition( + config.transition, + parentConfig.transition + ) /** * Don't allow isStatic to change between renders as it affects how many hooks diff --git a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts index d69a647514..c5bbcf8014 100644 --- a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts +++ b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts @@ -1,5 +1,6 @@ import { frame } from "../../frameloop" import { getValueTransition } from "../utils/get-value-transition" +import { resolveTransition } from "../utils/resolve-transition" import { positionalKeys } from "../../render/utils/keys-position" import { setTarget } from "../../render/utils/setters" import { addValueToWillChange } from "../../value/will-change/add-will-change" @@ -34,11 +35,16 @@ export function animateTarget( { delay = 0, transitionOverride, type }: VisualElementAnimationOptions = {} ): AnimationPlaybackControlsWithThen[] { let { - transition = visualElement.getDefaultTransition(), + transition, transitionEnd, ...target } = targetAndTransition + const defaultTransition = visualElement.getDefaultTransition() + transition = transition + ? resolveTransition(transition, defaultTransition) + : defaultTransition + const reduceMotion = (transition as { reduceMotion?: boolean })?.reduceMotion if (transitionOverride) transition = transitionOverride diff --git a/packages/motion-dom/src/animation/utils/get-value-transition.ts b/packages/motion-dom/src/animation/utils/get-value-transition.ts index 0f2081e399..4ea472de54 100644 --- a/packages/motion-dom/src/animation/utils/get-value-transition.ts +++ b/packages/motion-dom/src/animation/utils/get-value-transition.ts @@ -1,12 +1,13 @@ +import { resolveTransition } from "./resolve-transition" + export function getValueTransition(transition: any, key: string) { const valueTransition = transition?.[key as keyof typeof transition] ?? transition?.["default"] ?? transition - if (valueTransition?.inherit && valueTransition !== transition) { - const { inherit: _, ...rest } = valueTransition - return { ...transition, ...rest } + if (valueTransition !== transition) { + return resolveTransition(valueTransition, transition) } return valueTransition diff --git a/packages/motion-dom/src/animation/utils/resolve-transition.ts b/packages/motion-dom/src/animation/utils/resolve-transition.ts new file mode 100644 index 0000000000..d4537f4ffb --- /dev/null +++ b/packages/motion-dom/src/animation/utils/resolve-transition.ts @@ -0,0 +1,16 @@ +/** + * If `transition` has `inherit: true`, shallow-merge it with + * `parentTransition` (child keys win) and strip the `inherit` key. + * Otherwise return `transition` unchanged. + */ +export function resolveTransition( + transition: any, + parentTransition?: any +) { + if (transition?.inherit && parentTransition) { + const { inherit: _, ...rest } = transition + return { ...parentTransition, ...rest } + } + + return transition +} diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 9ca4a3159c..ef01fca0d8 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -12,6 +12,7 @@ export * from "./animation/utils/css-variables-conversion" export { getDefaultTransition } from "./animation/utils/default-transitions" export { getFinalKeyframe } from "./animation/utils/get-final-keyframe" export * from "./animation/utils/get-value-transition" +export * from "./animation/utils/resolve-transition" export * from "./animation/utils/is-css-variable" export { isTransitionDefined } from "./animation/utils/is-transition-defined" export * from "./animation/utils/make-animation-instant" From a244a28621a786529ae081053dc7ad340d670bbb Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 14:40:03 +0100 Subject: [PATCH 25/35] fix(drag): Add force:true to all triggers for oversized element Co-Authored-By: Claude Opus 4.5 --- .../cypress/integration/drag-momentum.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/framer-motion/cypress/integration/drag-momentum.ts b/packages/framer-motion/cypress/integration/drag-momentum.ts index d3e3e31a8f..5904115a66 100644 --- a/packages/framer-motion/cypress/integration/drag-momentum.ts +++ b/packages/framer-motion/cypress/integration/drag-momentum.ts @@ -4,11 +4,11 @@ describe("Drag Momentum", () => { .wait(200) .get("[data-testid='draggable']") .wait(200) - .trigger("pointerdown", 25, 900) + .trigger("pointerdown", 25, 900, { force: true }) .wait(300) // Simulate holding before flick - .trigger("pointermove", 25, 895) // Cross distance threshold + .trigger("pointermove", 25, 895, { force: true }) // Cross distance threshold .wait(50) - .trigger("pointermove", 25, 800) // Quick flick upward + .trigger("pointermove", 25, 800, { force: true }) // Quick flick upward .wait(50) .trigger("pointerup", { force: true }) .wait(500) // Wait for momentum to carry element @@ -29,10 +29,10 @@ describe("Drag Momentum", () => { .get("[data-testid='draggable']") .wait(200) // Perform a drag-and-throw upward - .trigger("pointerdown", 25, 900) - .trigger("pointermove", 25, 895) // Cross distance threshold + .trigger("pointerdown", 25, 900, { force: true }) + .trigger("pointermove", 25, 895, { force: true }) // Cross distance threshold .wait(50) - .trigger("pointermove", 25, 700) + .trigger("pointermove", 25, 700, { force: true }) .wait(50) .trigger("pointerup", { force: true }) // Wait for momentum to start From 4cd97b4ed04a30b379c37c6f6def8ce011d6cf80 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 14:07:53 +0100 Subject: [PATCH 26/35] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4149d0a280..d52652fcf6 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.31.3] 2026-02-05 + +### Fixed + +- ``: Ensure animation state is reset after being re-suspended. + ## [12.31.2] 2026-02-05 ### Fixed From 06c0883d41c57d9e8084e4f5d8cc6b9ef5e9db01 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 14:47:04 +0100 Subject: [PATCH 27/35] Updating changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52652fcf6..1efe5ec055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Undocumented APIs should be considered internal and may change without warning. ### Fixed - ``: Ensure animation state is reset after being re-suspended. +- Prevent stale values when mixing `transitionEnd` and `transition.type: false`. ## [12.31.2] 2026-02-05 From 461273f1004bffe275f4693d6957e1a7649aed82 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 14:51:28 +0100 Subject: [PATCH 28/35] Updating changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1efe5ec055..350a50ab65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Undocumented APIs should be considered internal and may change without warning. - ``: Ensure animation state is reset after being re-suspended. - Prevent stale values when mixing `transitionEnd` and `transition.type: false`. +- Drag: Fix "sticky" throw velocity on initial interaciton. +- Drag: Ensure catching a thrown element kills its velocity. ## [12.31.2] 2026-02-05 From cd4ec2310b101fb8c86a520ec51aa944d51f2187 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 14:51:46 +0100 Subject: [PATCH 29/35] v12.31.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 0e5eb81ed6..c8cc01d7db 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.31.2", + "version": "12.31.3", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.31.2", - "motion": "^12.31.2", - "motion-dom": "^12.31.2", + "framer-motion": "^12.31.3", + "motion": "^12.31.3", + "motion-dom": "^12.31.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index fc4379b485..0900933905 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.31.2", + "version": "12.31.3", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.31.2", + "motion": "^12.31.3", "next": "15.4.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 eb2e4e293b..ec836673a2 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.31.2", + "version": "12.31.3", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.31.2", + "motion": "^12.31.3", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index de40cd2172..942a4572b7 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.31.2", + "version": "12.31.3", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.31.2", + "framer-motion": "^12.31.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index e0c7913f05..af7cc3819a 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.31.2", + "version": "12.31.3", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 6800be4c45..012f6dde03 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.31.2", + "version": "12.31.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.31.2", + "motion-dom": "^12.31.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 f853bb9678..569e6933d2 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.31.2", + "version": "12.31.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 a64cb6ab4a..f52364e9dc 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.31.2", + "version": "12.31.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.31.2", + "framer-motion": "^12.31.3", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 197b61dcf0..1f694e031d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.31.2, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.31.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.31.2 + motion-dom: ^12.31.3 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8192,9 +8192,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.31.2 - motion: ^12.31.2 - motion-dom: ^12.31.2 + framer-motion: ^12.31.3 + motion: ^12.31.3 + motion-dom: ^12.31.3 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.31.2, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.31.3, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.31.2, motion@workspace:packages/motion": +"motion@^12.31.3, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.31.2 + framer-motion: ^12.31.3 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.31.2 + motion: ^12.31.3 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.31.2 + motion: ^12.31.3 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,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.31.2 + framer-motion: ^12.31.3 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From d4c0bcd51a90a77be35c933ba984c286f6153ae4 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 15:19:51 +0100 Subject: [PATCH 30/35] refactor(gestures): Rename stopTapPropagation to propagate={{ tap: false }} Avoids double negative naming and makes the API extensible for other gesture types (hover, drag, etc.). Also adds Playwright E2E tests verifying the stopPropagation option works at the motion-dom press() level. Co-Authored-By: Claude Opus 4.5 --- .../public/playwright/gestures/press.html | 19 ++++++++++ .../src/gestures/__tests__/press.test.tsx | 14 ++++---- packages/framer-motion/src/gestures/press.ts | 4 +-- .../src/motion/utils/valid-prop.ts | 2 +- packages/motion-dom/src/node/types.ts | 36 ++++++++++++------- tests/gestures/press.spec.ts | 24 +++++++++++++ 6 files changed, 76 insertions(+), 23 deletions(-) diff --git a/dev/html/public/playwright/gestures/press.html b/dev/html/public/playwright/gestures/press.html index 68ad250c98..ba5369916c 100644 --- a/dev/html/public/playwright/gestures/press.html +++ b/dev/html/public/playwright/gestures/press.html @@ -33,6 +33,10 @@
+
+
child
+
+ diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 12feefec73..77975ad6b5 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -767,7 +767,7 @@ describe("press", () => { ]) }) - test("stopTapPropagation prevents parent onTap from firing", async () => { + test("propagate={{ tap: false }} prevents parent onTap from firing", async () => { const parentTap = jest.fn() const childTap = jest.fn() const Component = () => ( @@ -775,7 +775,7 @@ describe("press", () => { childTap()} - stopTapPropagation + propagate={{ tap: false }} /> ) @@ -791,7 +791,7 @@ describe("press", () => { expect(parentTap).toBeCalledTimes(0) }) - test("without stopTapPropagation both parent and child onTap fire", async () => { + test("without propagate both parent and child onTap fire", async () => { const parentTap = jest.fn() const childTap = jest.fn() const Component = () => ( @@ -814,7 +814,7 @@ describe("press", () => { expect(parentTap).toBeCalledTimes(1) }) - test("stopTapPropagation isolates whileTap to child only", () => { + test("propagate={{ tap: false }} isolates whileTap to child only", () => { const promise = new Promise(async (resolve) => { const parentOpacityHistory: number[] = [] const childOpacityHistory: number[] = [] @@ -837,7 +837,7 @@ describe("press", () => { transition={{ type: false }} whileTap={{ opacity: 1 }} style={{ opacity: childOpacity }} - stopTapPropagation + propagate={{ tap: false }} />
) @@ -863,7 +863,7 @@ describe("press", () => { }) }) - test("stopTapPropagation prevents all ancestor onTap handlers (three levels)", async () => { + test("propagate={{ tap: false }} prevents all ancestor onTap handlers (three levels)", async () => { const grandparentTap = jest.fn() const parentTap = jest.fn() const childTap = jest.fn() @@ -873,7 +873,7 @@ describe("press", () => { childTap()} - stopTapPropagation + propagate={{ tap: false }} />
diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index 79585ae55d..f75f7b8a2f 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -32,7 +32,7 @@ export class PressGesture extends Feature { const { current } = this.node if (!current) return - const { globalTapTarget, stopTapPropagation } = this.node.props + const { globalTapTarget, propagate } = this.node.props this.unmount = press( current, @@ -48,7 +48,7 @@ export class PressGesture extends Feature { }, { useGlobalTarget: globalTapTarget, - stopPropagation: stopTapPropagation, + stopPropagation: propagate?.tap === false, } ) } diff --git a/packages/framer-motion/src/motion/utils/valid-prop.ts b/packages/framer-motion/src/motion/utils/valid-prop.ts index 0a1875d16b..515942d845 100644 --- a/packages/framer-motion/src/motion/utils/valid-prop.ts +++ b/packages/framer-motion/src/motion/utils/valid-prop.ts @@ -35,7 +35,7 @@ const validMotionProps = new Set([ "onViewportEnter", "onViewportLeave", "globalTapTarget", - "stopTapPropagation", + "propagate", "ignoreStrict", "viewport", ]) diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index 7923acb3bd..b7d49751fe 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -539,18 +539,6 @@ export interface MotionNodeTapHandlers { */ globalTapTarget?: boolean - /** - * If `true`, this element's tap gesture will prevent any parent - * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) - * from firing. - * - * ```jsx - * - * - * - * ``` - */ - stopTapPropagation?: boolean } /** @@ -1056,6 +1044,15 @@ export interface MotionNodeAdvancedOptions { "data-framer-appear-id"?: string } +export interface PropagateOptions { + /** + * If `false`, this element's tap gesture will prevent any parent + * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) + * from firing. Defaults to `true`. + */ + tap?: boolean +} + export interface MotionNodeOptions extends MotionNodeAnimationOptions, MotionNodeEventOptions, @@ -1067,4 +1064,17 @@ export interface MotionNodeOptions MotionNodeDragHandlers, MotionNodeDraggableOptions, MotionNodeLayoutOptions, - MotionNodeAdvancedOptions {} + MotionNodeAdvancedOptions { + /** + * Controls whether gesture events propagate to parent motion components. + * By default all gestures propagate. Set individual gestures to `false` + * to prevent parent handlers from firing. + * + * ```jsx + * + * + * + * ``` + */ + propagate?: PropagateOptions +} diff --git a/tests/gestures/press.spec.ts b/tests/gestures/press.spec.ts index 25a8b2b98d..b54e042eb9 100644 --- a/tests/gestures/press.spec.ts +++ b/tests/gestures/press.spec.ts @@ -245,6 +245,30 @@ test.describe("press events", () => { // await expect(windowOutput).toHaveValue("cancel") }) + test("stopPropagation prevents parent press from firing", async ({ + page, + }) => { + const child = page.locator("#propagate-child") + const output = page.locator("#propagate-output") + + // Press child - only child handlers should fire + await child.dispatchEvent("pointerdown", pointerOptions) + await child.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("child-start,child-end,") + }) + + test("parent press fires when clicking outside child", async ({ + page, + }) => { + const parent = page.locator("#propagate-parent") + const output = page.locator("#propagate-output") + + // Press parent directly - parent handlers should fire + await parent.dispatchEvent("pointerdown", pointerOptions) + await parent.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("parent-start,parent-end,") + }) + test("nested click handlers", async ({ page }) => { const button = page.locator("#press-click-button") const box = await button.boundingBox() From 69b1c9714bd5e65f2a64bfb7d979911af4d65107 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 15:27:08 +0100 Subject: [PATCH 31/35] Updating changelog --- CHANGELOG.md | 6 ++++++ package.json | 2 ++ yarn.lock | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350a50ab65..8085baefa9 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.32.0] 2026-02-05 + +### Added + +- `transition.inherit`: When `true`, inherit transition values from less-specific transitions. + ## [12.31.3] 2026-02-05 ### Fixed diff --git a/package.json b/package.json index fef50eb2c6..948cb589c7 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-redos-detector": "^2.4.0", "eslint-plugin-regexp": "^2.2.0", + "framer-api": "^0.1.0", "gsap": "^3.12.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -64,6 +65,7 @@ "jest-watch-typeahead": "^2.2.2", "lerna": "^4.0.0", "lint-staged": "^8.0.4", + "papaparse": "^5.5.3", "path-browserify": "^1.0.1", "prettier": "^2.5.1", "react": "^18.3.1", diff --git a/yarn.lock b/yarn.lock index 1f694e031d..470bd7695d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6082,6 +6082,13 @@ __metadata: languageName: node linkType: hard +"devalue@npm:^5.6.2": + version: 5.6.2 + resolution: "devalue@npm:5.6.2" + checksum: 9d031092e3a6ff3a98820261375826ae88846c006857713816b7c1c007224982bdf94abc93b0f33218baff40f4b9d635001d1dfef0860a9e4074d8b7eac4f476 + languageName: node + linkType: hard + "dezalgo@npm:^1.0.0": version: 1.0.4 resolution: "dezalgo@npm:1.0.4" @@ -7420,6 +7427,16 @@ __metadata: languageName: node linkType: hard +"framer-api@npm:^0.1.0": + version: 0.1.0 + resolution: "framer-api@npm:0.1.0" + dependencies: + devalue: ^5.6.2 + std-env: ^3.10.0 + checksum: 8224594208a42d5c52ea164a649b9ad8a8eb6608a4a1840f6a4b44badcda3facbffdb301ee66e2df4ebacd98bbe04c867a0c1b9bf5f6ae30e23ccae127d03003 + languageName: node + linkType: hard + "framer-motion@^12.31.3, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" @@ -10978,6 +10995,7 @@ __metadata: eslint-plugin-react-hooks: ^4.6.0 eslint-plugin-redos-detector: ^2.4.0 eslint-plugin-regexp: ^2.2.0 + framer-api: ^0.1.0 gsap: ^3.12.5 jest: ^29.7.0 jest-environment-jsdom: ^29.7.0 @@ -10985,6 +11003,7 @@ __metadata: jest-watch-typeahead: ^2.2.2 lerna: ^4.0.0 lint-staged: ^8.0.4 + papaparse: ^5.5.3 path-browserify: ^1.0.1 prettier: ^2.5.1 react: ^18.3.1 @@ -11987,6 +12006,13 @@ __metadata: languageName: node linkType: hard +"papaparse@npm:^5.5.3": + version: 5.5.3 + resolution: "papaparse@npm:5.5.3" + checksum: 369d68a16340e5fad95d411a0efca34bedbf93550744e6374fa9b60aaf6bc655e29a6d1a39a56afea0cf7dbc4454fd190f50a9ad76db80987b43d6c6c319f018 + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -14345,6 +14371,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 51d641b36b0fae494a546fb8446d39a837957fbf902c765c62bd12af8e50682d141c4087ca032f1192fa90330c4f6ff23fd6c9795324efacd1684e814471e0e0 + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0, stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" From b962d0976dea946db43fcf49a66861b205116ec4 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 15:27:28 +0100 Subject: [PATCH 32/35] v12.32.0 --- 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 c8cc01d7db..d0d00a346a 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.31.3", + "version": "12.32.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.31.3", - "motion": "^12.31.3", - "motion-dom": "^12.31.3", + "framer-motion": "^12.32.0", + "motion": "^12.32.0", + "motion-dom": "^12.32.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 0900933905..b28e6628b3 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.31.3", + "version": "12.32.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.31.3", + "motion": "^12.32.0", "next": "15.4.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 ec836673a2..c804465b55 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.31.3", + "version": "12.32.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.31.3", + "motion": "^12.32.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 942a4572b7..a9196e5b00 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.31.3", + "version": "12.32.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.31.3", + "framer-motion": "^12.32.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index af7cc3819a..f24824c58d 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.31.3", + "version": "12.32.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 012f6dde03..987edf547f 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.31.3", + "version": "12.32.0", "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.31.3", + "motion-dom": "^12.32.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 569e6933d2..616e64f938 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.31.3", + "version": "12.32.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index f52364e9dc..faf5a64f1c 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.31.3", + "version": "12.32.0", "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.31.3", + "framer-motion": "^12.32.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 470bd7695d..52d9e73946 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.31.3, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.32.0, 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.31.3 + motion-dom: ^12.32.0 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.31.3 - motion: ^12.31.3 - motion-dom: ^12.31.3 + framer-motion: ^12.32.0 + motion: ^12.32.0 + motion-dom: ^12.32.0 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.31.3, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.32.0, 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.31.3, motion@workspace:packages/motion": +"motion@^12.32.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.31.3 + framer-motion: ^12.32.0 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.31.3 + motion: ^12.32.0 next: 15.4.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.31.3 + motion: ^12.32.0 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.31.3 + framer-motion: ^12.32.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 0fec416782a9fbd6c6f7f393e4594292284981b1 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 15:54:04 +0100 Subject: [PATCH 33/35] Adding changelog updates --- package.json | 3 +- scripts/push-to-site.js | 160 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 scripts/push-to-site.js diff --git a/package.json b/package.json index 948cb589c7..acd10bd2d3 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,9 @@ "version": "yarn install && git stage yarn.lock", "prepare": "turbo run build measure", "generate-changelog-csv": "node scripts/generate-changelog.js", - "new": "yarn generate-changelog-csv && yarn test-playwright && lerna publish from-package && yarn notify-slack", + "new": "yarn generate-changelog-csv && yarn test-playwright && lerna publish from-package && yarn notify-slack && yarn push-to-site", "new-alpha": "turbo run build && lerna publish from-package --canary --preid alpha", + "push-to-site": "node scripts/push-to-site.js", "notify-slack": "NODE_ENV=production node scripts/notify-slack.js", "test-notify-slack": "NODE_ENV=development node scripts/notify-slack.js" }, diff --git a/scripts/push-to-site.js b/scripts/push-to-site.js new file mode 100644 index 0000000000..aed1f76cb2 --- /dev/null +++ b/scripts/push-to-site.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +// Load environment variables from .env file +require("dotenv").config() + +const fs = require("fs") +const path = require("path") +const Papa = require("papaparse") + +async function pushToSite() { + try { + const projectId = process.env.FRAMER_PROJECT_ID + if (!projectId) { + throw new Error( + "FRAMER_PROJECT_ID environment variable is required" + ) + } + + // Parse changelog.csv + const csvPath = path.join(__dirname, "..", "changelog.csv") + if (!fs.existsSync(csvPath)) { + throw new Error(`changelog.csv not found at ${csvPath}`) + } + + const csvContent = fs.readFileSync(csvPath, "utf8") + const { data: rows } = Papa.parse(csvContent, { + header: true, + skipEmptyLines: true, + transformHeader: (header) => header.trim(), + transform: (value) => value.trim(), + }) + + console.log(`📄 Parsed ${rows.length} entries from changelog.csv`) + + // Dynamic import for ESM-only framer-api + const { connect } = await import("framer-api") + + console.log(`🔗 Connecting to Framer...`) + const framer = await connect(projectId) + + try { + // Find the "Changelog" collection + const collections = await framer.getCollections() + const collection = collections.find( + (c) => c.name === "Changelog" + ) + + if (!collection) { + throw new Error( + 'Collection "Changelog" not found. Please create it in Framer first.' + ) + } + + console.log(`📦 Found "Changelog" collection`) + + // Map field names → field metadata + const fields = await collection.getFields() + const fieldNameToId = new Map( + fields.map((f) => [f.name.toLowerCase(), f.id]) + ) + + // For enum fields, build a case name → case ID map + const enumCaseMaps = new Map() + for (const field of fields) { + if (field.type === "enum") { + const caseMap = new Map( + field.cases.map((c) => [c.name.toLowerCase(), c.id]) + ) + enumCaseMaps.set(field.id, caseMap) + } + } + + // Collect existing slugs + const existingItems = await collection.getItems() + const existingSlugs = new Set( + existingItems.map((item) => item.slug) + ) + + console.log(`📋 ${existingItems.length} existing items in collection`) + + // Filter to only new entries + const newRows = rows.filter((row) => !existingSlugs.has(row.slug)) + + if (newRows.length === 0) { + console.log(`✅ No new entries to add`) + } else { + // Build items + const newItems = newRows.map((row) => { + const fieldData = {} + + const versionFieldId = fieldNameToId.get("version") + if (versionFieldId) { + fieldData[versionFieldId] = { + type: "string", + value: row.version, + } + } + + const dateFieldId = fieldNameToId.get("date") + if (dateFieldId) { + fieldData[dateFieldId] = { + type: "date", + value: row.date, + } + } + + const contentFieldId = fieldNameToId.get("content") + if (contentFieldId) { + fieldData[contentFieldId] = { + type: "formattedText", + value: row.content, + contentType: "markdown", + } + } + + const typeFieldId = fieldNameToId.get("type") + if (typeFieldId) { + const caseMap = enumCaseMaps.get(typeFieldId) + const caseId = caseMap?.get(row.type?.toLowerCase()) + if (caseId) { + fieldData[typeFieldId] = { + type: "enum", + value: caseId, + } + } + } + + return { slug: row.slug, fieldData } + }) + + await collection.addItems(newItems) + console.log(`✅ Added ${newItems.length} new entries`) + } + + // Publish the site + console.log(`🚀 Publishing site...`) + const result = await framer.publish() + console.log(`✅ Site published`) + + if (result?.hostnames?.length > 0) { + const primary = result.hostnames.find((h) => h.isPrimary) + if (primary) { + console.log(` URL: https://${primary.hostname}`) + } + } + } finally { + await framer.disconnect() + } + } catch (error) { + console.error(`❌ Error pushing to site:`, error.message) + process.exit(1) + } +} + +// Run the script +if (require.main === module) { + pushToSite() +} + +module.exports = { pushToSite } From 4849b3af9a7980b2a759c5ed9b32366812a35889 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 16:18:28 +0100 Subject: [PATCH 34/35] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8085baefa9..3fd5279ed7 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.33.0] 2026-02-05 + +### Added + +- ``: New `propagate.tap` prop prevents tap gestures from propagating to parents. + ## [12.32.0] 2026-02-05 ### Added From fde47ac120d6f82ef676318c1801b952fe10493a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Thu, 5 Feb 2026 16:18:47 +0100 Subject: [PATCH 35/35] v12.33.0 --- 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 d0d00a346a..344e112257 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.32.0", + "version": "12.33.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.32.0", - "motion": "^12.32.0", - "motion-dom": "^12.32.0", + "framer-motion": "^12.33.0", + "motion": "^12.33.0", + "motion-dom": "^12.33.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index b28e6628b3..438de224b8 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.32.0", + "version": "12.33.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.32.0", + "motion": "^12.33.0", "next": "15.4.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 c804465b55..54b73dc456 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.32.0", + "version": "12.33.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.32.0", + "motion": "^12.33.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index a9196e5b00..39f30c7557 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.32.0", + "version": "12.33.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.32.0", + "framer-motion": "^12.33.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index f24824c58d..cccd6e05fd 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.32.0", + "version": "12.33.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 987edf547f..59ac460c5d 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.32.0", + "version": "12.33.0", "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.32.0", + "motion-dom": "^12.33.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 616e64f938..1cf9ede8ba 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.32.0", + "version": "12.33.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index faf5a64f1c..3db1446f2e 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.32.0", + "version": "12.33.0", "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.32.0", + "framer-motion": "^12.33.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 52d9e73946..60d07fcb72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.32.0, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.33.0, 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.32.0 + motion-dom: ^12.33.0 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.32.0 - motion: ^12.32.0 - motion-dom: ^12.32.0 + framer-motion: ^12.33.0 + motion: ^12.33.0 + motion-dom: ^12.33.0 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.32.0, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.33.0, 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.32.0, motion@workspace:packages/motion": +"motion@^12.33.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.32.0 + framer-motion: ^12.33.0 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.32.0 + motion: ^12.33.0 next: 15.4.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.32.0 + motion: ^12.33.0 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.32.0 + framer-motion: ^12.33.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0