diff --git a/CHANGELOG.md b/CHANGELOG.md index 359915f5ef..6a7a1a7056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.31.0] 2026-02-03 + +### Added + +- `animate`: Support for bi-directional callbacks within animation sequences. + +### Fixed + +- Ensure `onPan` never fires before `onPanStart`. + ## [12.30.1] 2026-02-03 ### Fixed diff --git a/dev/html/package.json b/dev/html/package.json index d7d43cdd73..86233b9745 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.30.1", + "version": "12.31.0", "type": "module", "scripts": { "dev": "vite", @@ -10,8 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.30.1", - "motion": "^12.30.1", + "framer-motion": "^12.31.0", + "motion": "^12.31.0", "motion-dom": "^12.30.1", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/dev/next/package.json b/dev/next/package.json index 26c138a4a3..c5290d60a2 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.30.1", + "version": "12.31.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.30.1", + "motion": "^12.31.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 e258ab4910..b9d6fe6d7b 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.30.1", + "version": "12.31.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.30.1", + "motion": "^12.31.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index aa89f7b533..36ac4e41cb 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.30.1", + "version": "12.31.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.30.1", + "framer-motion": "^12.31.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 5ac1a87dcd..01d43bce76 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.30.1", + "version": "12.31.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index ca60828950..bfb25eac8e 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.30.1", + "version": "12.31.0", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", diff --git a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx index ea1f0fddd7..a6779630ea 100644 --- a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx @@ -373,6 +373,126 @@ describe("animate", () => { }) }) +describe("Sequence callbacks", () => { + function waitForFrame(): Promise { + return new Promise((resolve) => setTimeout(resolve, 50)) + } + + test("Progress callback receives interpolated values", async () => { + const element = document.createElement("div") + const values: number[] = [] + + const animation = animate( + [ + [element, { opacity: 1 }, { duration: 0.5 }], + [(p: number) => values.push(p), { duration: 0.5 }], + ], + { + defaultTransition: { + ease: "linear", + }, + } + ) + + await animation.then(() => { + expect(values.length).toBeGreaterThan(0) + expect(values[values.length - 1]).toBe(1) + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThanOrEqual(1) + } + }) + }) + + test("Progress callback with custom keyframes", async () => { + const element = document.createElement("div") + const values: number[] = [] + + const animation = animate( + [ + [element, { opacity: 1 }, { duration: 0.5 }], + [(v: number) => values.push(v), [0, 100], { duration: 0.5 }], + ], + { + defaultTransition: { + ease: "linear", + }, + } + ) + + await animation.then(() => { + expect(values.length).toBeGreaterThan(0) + expect(values[values.length - 1]).toBe(100) + for (const v of values) { + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThanOrEqual(100) + } + }) + }) + + test("Toggle helper for do/undo pattern", async () => { + const element = document.createElement("div") + let doCount = 0 + let undoCount = 0 + + function toggle( + onDo: VoidFunction, + onUndo?: VoidFunction + ) { + let done = false + return (p: number) => { + if (p >= 1 && !done) { + done = true + onDo() + } else if (p < 1 && done) { + done = false + onUndo?.() + } + } + } + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + toggle( + () => doCount++, + () => undoCount++ + ), + { duration: 0 }, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + expect(animation.duration).toBe(2) + + animation.pause() + + // Scrub to 0.5 - toggle not yet fired (callback is at t=1) + animation.time = 0.5 + await waitForFrame() + expect(doCount).toBe(0) + expect(undoCount).toBe(0) + + // Scrub past threshold - do fires + animation.time = 1.5 + await waitForFrame() + expect(doCount).toBe(1) + expect(undoCount).toBe(0) + + // Scrub back before threshold - undo fires + animation.time = 0.5 + await waitForFrame() + expect(doCount).toBe(1) + expect(undoCount).toBe(1) + + // Scrub forward again - do fires again + animation.time = 1.5 + await waitForFrame() + expect(doCount).toBe(2) + expect(undoCount).toBe(1) + }) +}) + describe("animate: Objects", () => { test("Types: Object to object", () => { animate({ x: 100 }, { x: 200 }) diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index b26fbc4c47..76b2dd1017 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -1,6 +1,7 @@ import { AnimationPlaybackControlsWithThen, AnimationScope, + motionValue, spring, } from "motion-dom" import { createAnimationsFromSequence } from "../sequence/create" @@ -14,8 +15,29 @@ export function animateSequence( ) { const animations: AnimationPlaybackControlsWithThen[] = [] + /** + * Pre-process: replace function segments with MotionValue segments, + * subscribe callbacks immediately + */ + const processedSequence = sequence.map((segment) => { + if (Array.isArray(segment) && typeof segment[0] === "function") { + const callback = segment[0] as (value: any) => void + const mv = motionValue(0) + mv.on("change", callback) + + if (segment.length === 1) { + return [mv, [0, 1]] as any + } else if (segment.length === 2) { + return [mv, [0, 1], segment[1]] as any + } else { + return [mv, segment[1], segment[2]] as any + } + } + return segment + }) as AnimationSequence + const animationDefinitions = createAnimationsFromSequence( - sequence, + processedSequence, options, scope, { spring } diff --git a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts index 75d031dd87..7a2aea21e3 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -838,3 +838,48 @@ describe("createAnimationsFromSequence", () => { expect(animations.size).toBe(0) }) }) + +describe("Sequence callbacks", () => { + const a = document.createElement("div") + const b = document.createElement("div") + + test("Function segments as MotionValues don't affect element animation timing", () => { + const mv1 = motionValue(0) + const mv2 = motionValue(0) + const mv3 = motionValue(0) + + const animations = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [mv1, [0, 1], { duration: 0 }], + [mv2, [0, 1], { duration: 0 }], + [mv3, [0, 1], { duration: 0 }], + [b, { y: 200 }, { duration: 1 }], + ], + undefined, + undefined, + { spring } + ) + + expect(animations.get(a)!.transition.x.duration).toBe(2) + expect(animations.get(a)!.transition.x.times).toEqual([0, 0.5, 1]) + expect(animations.get(b)!.transition.y.times).toEqual([0, 0.5, 1]) + }) + + test("Function segments appear as MotionValue entries in animation definitions", () => { + const mv = motionValue(0) + + const animations = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [mv, [0, 1], { duration: 0.5 }], + ], + undefined, + undefined, + { spring } + ) + + expect(animations.has(a)).toBe(true) + expect(animations.has(mv)).toBe(true) + }) +}) diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index 2797db4e4d..9cdbb7cc42 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -58,6 +58,17 @@ export type ObjectSegmentWithTransition = [ DynamicAnimationOptions & At ] +export type SequenceProgressCallback = (value: any) => void + +export type FunctionSegment = + | [SequenceProgressCallback] + | [SequenceProgressCallback, DynamicAnimationOptions & At] + | [ + SequenceProgressCallback, + UnresolvedValueKeyframe | UnresolvedValueKeyframe[], + DynamicAnimationOptions & At + ] + export type Segment = | ObjectSegment | ObjectSegmentWithTransition @@ -67,6 +78,7 @@ export type Segment = | MotionValueSegmentWithTransition | DOMSegment | DOMSegmentWithTransition + | FunctionSegment export type AnimationSequence = Segment[] @@ -98,3 +110,4 @@ export type ResolvedAnimationDefinitions = Map< Element | MotionValue, ResolvedAnimationDefinition > + diff --git a/packages/framer-motion/src/gestures/__tests__/pan.test.tsx b/packages/framer-motion/src/gestures/__tests__/pan.test.tsx index c7cb7aaa72..faa80f716d 100644 --- a/packages/framer-motion/src/gestures/__tests__/pan.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/pan.test.tsx @@ -44,6 +44,40 @@ describe("pan", () => { expect(count).toBeGreaterThan(0) }) + test("onPanStart fires before onPan", async () => { + const events: string[] = [] + const onPanEnd = deferred() + const Component = () => { + return ( + + events.push("start")} + onPan={() => events.push("pan")} + onPanEnd={() => { + events.push("end") + onPanEnd.resolve() + }} + /> + + ) + } + + const { container, rerender } = render() + rerender() + + const pointer = await drag(container.firstChild).to(100, 100) + await dragFrame.postRender() + pointer.end() + await onPanEnd.promise + + // onPanStart should fire before the first onPan + const startIndex = events.indexOf("start") + const firstPanIndex = events.indexOf("pan") + expect(startIndex).toBeGreaterThanOrEqual(0) + expect(firstPanIndex).toBeGreaterThanOrEqual(0) + expect(startIndex).toBeLessThan(firstPanIndex) + }) + test("onPanEnd doesn't fire unless onPanStart has", async () => { const onPanStart = jest.fn() const onPanEnd = jest.fn() diff --git a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts index d19b302c4c..1ac4fb3e4f 100644 --- a/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts +++ b/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts @@ -175,7 +175,7 @@ export class VisualElementDragControls { // Fire onDragStart event if (onDragStart) { - frame.postRender(() => onDragStart(event, info)) + frame.update(() => onDragStart(event, info), false, true) } addValueToWillChange(this.visualElement, "transform") @@ -227,7 +227,9 @@ export class VisualElementDragControls { * This must fire after the render call as it might trigger a state * change which itself might trigger a layout update. */ - onDrag && onDrag(event, info) + if (onDrag) { + frame.update(() => onDrag(event, info), false, true) + } } const onSessionEnd = (event: PointerEvent, info: PanInfo) => { diff --git a/packages/framer-motion/src/gestures/pan/index.ts b/packages/framer-motion/src/gestures/pan/index.ts index 42de424e27..3f9113c980 100644 --- a/packages/framer-motion/src/gestures/pan/index.ts +++ b/packages/framer-motion/src/gestures/pan/index.ts @@ -8,7 +8,7 @@ type PanEventHandler = (event: PointerEvent, info: PanInfo) => void const asyncHandler = (handler?: PanEventHandler) => (event: PointerEvent, info: PanInfo) => { if (handler) { - frame.postRender(() => handler(event, info)) + frame.update(() => handler(event, info), false, true) } } @@ -35,7 +35,7 @@ export class PanGesture extends Feature { return { onSessionStart: asyncHandler(onPanSessionStart), onStart: asyncHandler(onPanStart), - onMove: onPan, + onMove: asyncHandler(onPan), onEnd: (event: PointerEvent, info: PanInfo) => { delete this.session if (onPanEnd) { diff --git a/packages/motion/package.json b/packages/motion/package.json index ba1ead3ab4..e825a2018a 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.30.1", + "version": "12.31.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.30.1", + "framer-motion": "^12.31.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 1830c98867..6069017874 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,7 +7420,7 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.30.1, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.31.0, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: @@ -8192,8 +8192,8 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.30.1 - motion: ^12.30.1 + framer-motion: ^12.31.0 + motion: ^12.31.0 motion-dom: ^12.30.1 react: ^18.3.1 react-dom: ^18.3.1 @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.30.1, motion@workspace:packages/motion": +"motion@^12.31.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.30.1 + framer-motion: ^12.31.0 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.30.1 + motion: ^12.31.0 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.30.1 + motion: ^12.31.0 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.30.1 + framer-motion: ^12.31.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0