Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions dev/html/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "html-env",
"private": true,
"version": "12.30.1",
"version": "12.31.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions dev/next/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "next-env",
"private": true,
"version": "12.30.1",
"version": "12.31.0",
"type": "module",
"scripts": {
"dev": "next dev",
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions dev/react-19/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-19-env",
"private": true,
"version": "12.30.1",
"version": "12.31.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions dev/react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-env",
"private": true,
"version": "12.30.1",
"version": "12.31.0",
"type": "module",
"scripts": {
"dev": "yarn vite",
Expand All @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "12.30.1",
"version": "12.31.0",
"packages": [
"packages/*",
"dev/*"
Expand Down
2 changes: 1 addition & 1 deletion packages/framer-motion/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,126 @@ describe("animate", () => {
})
})

describe("Sequence callbacks", () => {
function waitForFrame(): Promise<void> {
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 })
Expand Down
24 changes: 23 additions & 1 deletion packages/framer-motion/src/animation/animate/sequence.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AnimationPlaybackControlsWithThen,
AnimationScope,
motionValue,
spring,
} from "motion-dom"
import { createAnimationsFromSequence } from "../sequence/create"
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
13 changes: 13 additions & 0 deletions packages/framer-motion/src/animation/sequence/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export type ObjectSegmentWithTransition<O extends {} = {}> = [
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
Expand All @@ -67,6 +78,7 @@ export type Segment =
| MotionValueSegmentWithTransition
| DOMSegment
| DOMSegmentWithTransition
| FunctionSegment

export type AnimationSequence = Segment[]

Expand Down Expand Up @@ -98,3 +110,4 @@ export type ResolvedAnimationDefinitions = Map<
Element | MotionValue,
ResolvedAnimationDefinition
>

34 changes: 34 additions & 0 deletions packages/framer-motion/src/gestures/__tests__/pan.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<MockDrag>
<motion.div
onPanStart={() => events.push("start")}
onPan={() => events.push("pan")}
onPanEnd={() => {
events.push("end")
onPanEnd.resolve()
}}
/>
</MockDrag>
)
}

const { container, rerender } = render(<Component />)
rerender(<Component />)

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()
Expand Down
Loading
Loading