From 5712ff961db196a2027bc65f6c9e2fa89f53d9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:11:54 +0000 Subject: [PATCH 01/16] feat: Add callback support to animation sequences This implements #2205 - callbacks can now be injected at specific points in an animation sequence. Callbacks support both forward (onEnter) and backward (onLeave) firing for scrubbing support. Usage: ```js animate([ [element, { x: 100 }, { duration: 1 }], [{ onEnter: () => console.log("midpoint!") }, { at: 0.5 }], [element, { y: 200 }, { duration: 1 }], ]) ``` Implementation has minimal overhead: - SequenceCallbackAnimation is lightweight (no DOM ops, just time tracking) - Callbacks are sorted and stored separately from animations - Only fires callbacks when time crosses thresholds https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF --- .../src/animation/animate/sequence.ts | 21 +- .../animators/waapi/animate-sequence.ts | 21 +- .../sequence/__tests__/index.test.ts | 250 +++++++++++++++--- .../src/animation/sequence/create.ts | 51 +++- .../src/animation/sequence/types.ts | 22 ++ .../animation/SequenceCallbackAnimation.ts | 127 +++++++++ .../SequenceCallbackAnimation.test.ts | 158 +++++++++++ packages/motion-dom/src/index.ts | 1 + 8 files changed, 604 insertions(+), 47 deletions(-) create mode 100644 packages/motion-dom/src/animation/SequenceCallbackAnimation.ts create mode 100644 packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index b26fbc4c47..f05addd876 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -1,8 +1,10 @@ import { AnimationPlaybackControlsWithThen, AnimationScope, + SequenceCallbackAnimation, spring, } from "motion-dom" +import { secondsToMilliseconds } from "motion-utils" import { createAnimationsFromSequence } from "../sequence/create" import { AnimationSequence, SequenceOptions } from "../sequence/types" import { animateSubject } from "./subject" @@ -14,16 +16,23 @@ export function animateSequence( ) { const animations: AnimationPlaybackControlsWithThen[] = [] - const animationDefinitions = createAnimationsFromSequence( - sequence, - options, - scope, - { spring } - ) + const { animationDefinitions, callbacks, totalDuration } = + createAnimationsFromSequence(sequence, options, scope, { spring }) animationDefinitions.forEach(({ keyframes, transition }, subject) => { animations.push(...animateSubject(subject, keyframes, transition)) }) + // Add callback animation if there are any callbacks + if (callbacks.length > 0) { + const callbackAnimation = new SequenceCallbackAnimation( + callbacks, + secondsToMilliseconds(totalDuration) + ) + animations.push( + callbackAnimation as unknown as AnimationPlaybackControlsWithThen + ) + } + return animations } diff --git a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts index a4dd43a43f..4af7d14ebb 100644 --- a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts +++ b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts @@ -1,4 +1,9 @@ -import { AnimationPlaybackControls, GroupAnimationWithThen } from "motion-dom" +import { + AnimationPlaybackControls, + GroupAnimationWithThen, + SequenceCallbackAnimation, +} from "motion-dom" +import { secondsToMilliseconds } from "motion-utils" import { createAnimationsFromSequence } from "../../sequence/create" import { AnimationSequence, SequenceOptions } from "../../sequence/types" import { animateElements } from "./animate-elements" @@ -9,11 +14,23 @@ export function animateSequence( ) { const animations: AnimationPlaybackControls[] = [] - createAnimationsFromSequence(definition, options).forEach( + const { animationDefinitions, callbacks, totalDuration } = + createAnimationsFromSequence(definition, options) + + animationDefinitions.forEach( ({ keyframes, transition }, element: Element) => { animations.push(...animateElements(element, keyframes, transition)) } ) + // Add callback animation if there are any callbacks + if (callbacks.length > 0) { + const callbackAnimation = new SequenceCallbackAnimation( + callbacks, + secondsToMilliseconds(totalDuration) + ) + animations.push(callbackAnimation as unknown as AnimationPlaybackControls) + } + return new GroupAnimationWithThen(animations) } 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..92c08e7000 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -1,6 +1,26 @@ import { motionValue, spring, stagger } from "motion-dom" import { Easing } from "motion-utils" import { createAnimationsFromSequence } from "../create" +import { AnimationSequence, SequenceOptions } from "../types" + +/** + * Helper to maintain backward compatibility with tests. + * Extracts just the animationDefinitions map from the new return structure. + */ +function getAnimationDefinitions( + sequence: AnimationSequence, + options?: SequenceOptions, + scope?: undefined, + generators?: { spring: typeof spring } +) { + const result = createAnimationsFromSequence( + sequence, + options, + scope, + generators + ) + return result.animationDefinitions +} describe("createAnimationsFromSequence", () => { const a = document.createElement("div") @@ -9,7 +29,7 @@ describe("createAnimationsFromSequence", () => { const value = motionValue(0) test("It creates a single animation", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -34,7 +54,7 @@ describe("createAnimationsFromSequence", () => { }) test("It orders grouped keyframes correctly", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }], [a, { x: [200, 300] }], @@ -48,7 +68,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates a single animation with defaults", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[a, { opacity: 1 }, { duration: 1 }]], undefined, undefined, @@ -64,7 +84,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates a single animation with defaults - 2", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -89,7 +109,7 @@ describe("createAnimationsFromSequence", () => { }) test("It assigns the correct easing to the correct keyframes", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 1 }, { duration: 1, ease: "circIn" }], [a, { x: 2, opacity: 0 }, { duration: 1, ease: "backInOut" }], @@ -115,7 +135,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sequences one animation after another", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -159,7 +179,7 @@ describe("createAnimationsFromSequence", () => { }) test("It accepts motion values", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[value, 100, { duration: 0.5 }]], undefined, undefined, @@ -175,7 +195,7 @@ describe("createAnimationsFromSequence", () => { }) test("It accepts motion values keyframes", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[value, [50, 100], { duration: 0.5 }]], undefined, undefined, @@ -191,7 +211,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds relative time to another animation", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "+0.5" }], @@ -217,7 +237,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds moves the playhead back to the previous animation", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "<" }], @@ -243,7 +263,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds subtracts time to another animation", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "-1" }], @@ -269,7 +289,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets another animation at a specific time", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: 1.5 }], @@ -295,7 +315,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets labels from strings", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }, { duration: 1 }], "my label", @@ -330,7 +350,7 @@ describe("createAnimationsFromSequence", () => { }) test("Can set label as first item in sequence", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ "my label", [a, { opacity: 0 }, { duration: 1 }], @@ -357,7 +377,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets annotated labels with absolute at times", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }, { duration: 1 }], { name: "my label", at: 0 }, @@ -392,7 +412,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets annotated labels with relative at times", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 100 }, { duration: 1 }], { name: "my label", at: "-1" }, @@ -427,7 +447,7 @@ describe("createAnimationsFromSequence", () => { }) test("It advances time by the maximum defined in individual value options", () => { - const animations = createAnimationsFromSequence([ + const animations = getAnimationDefinitions([ [a, { x: 1, y: 1 }, { duration: 1, y: { duration: 2 } }], [b, { y: 1 }, { duration: 0.5 }], ]) @@ -437,7 +457,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates multiple animations for multiple targets", () => { - const animations = createAnimationsFromSequence([[[a, b, c], { x: 1 }]]) + const animations = getAnimationDefinitions([[[a, b, c], { x: 1 }]]) expect(animations.get(a)).toBeTruthy() expect(animations.get(b)).toBeTruthy() @@ -445,7 +465,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates multiple animations, staggered", () => { - const animations = createAnimationsFromSequence([ + const animations = getAnimationDefinitions([ [[a, b, c], { x: 1 }, { delay: stagger(1), duration: 1 }], [a, { opacity: 1 }, { duration: 1 }], ]) @@ -479,7 +499,7 @@ describe("createAnimationsFromSequence", () => { }) test("It scales the whole animation based on the provided duration", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -499,7 +519,7 @@ describe("createAnimationsFromSequence", () => { }) test("It passes timeline options to children", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -525,7 +545,7 @@ describe("createAnimationsFromSequence", () => { }) test("It passes default options to children", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[a, { opacity: 1 }, { times: [0, 1] }]], { defaultTransition: { duration: 2, ease: "easeInOut" } } ) @@ -539,7 +559,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly passes easing cubic bezier array to children", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[a, { opacity: 1 }, { times: [0, 1] }]], { defaultTransition: { duration: 2, ease: [0, 1, 2, 3] } } ) @@ -556,7 +576,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds spring as duration-based easing when only one keyframe defined", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 200 }, { duration: 1 }], [a, { x: 0 }, { duration: 1, type: "spring", bounce: 0 }], @@ -577,7 +597,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds spring as duration-based easing when only one keyframe defined", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[a, { x: [0, 100] }, { type: "spring" }]], undefined, undefined, @@ -592,7 +612,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds springs as duration-based simulation when two keyframes defined", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 200 }, { duration: 1, ease: "linear" }], [ @@ -617,7 +637,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly adds type: spring to timeline with simulated spring", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 200 }, { duration: 1 }], [ @@ -642,7 +662,7 @@ describe("createAnimationsFromSequence", () => { }) test("Does not include type: spring in transition when spring is converted to easing via defaultTransition", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: 0 }, { duration: 0 }], [a, { x: 1.12 }], @@ -667,7 +687,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly repeats keyframes once", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[a, { x: [0, 100] }, { duration: 1, repeat: 1, ease: "linear" }]], undefined, undefined, @@ -682,7 +702,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly repeats easing", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -710,7 +730,7 @@ describe("createAnimationsFromSequence", () => { }) test("Repeating a segment correctly places the next segment at the end", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { x: [0, 100] }, { duration: 1, repeat: 1 }], [a, { y: [0, 100] }, { duration: 2 }], @@ -731,7 +751,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly adds repeatDelay between repeated keyframes", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -751,7 +771,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly mirrors repeated keyframes", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -773,7 +793,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly reverses repeated keyframes", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [ a, @@ -795,7 +815,7 @@ describe("createAnimationsFromSequence", () => { }) test("It skips null elements in sequence", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [ [a, { opacity: 1 }, { duration: 1 }], [null as unknown as Element, { opacity: 0.5 }, { duration: 1 }], @@ -813,7 +833,7 @@ describe("createAnimationsFromSequence", () => { }) test("It filters null elements from array of targets", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[[a, null as unknown as Element, b], { x: 100 }, { duration: 1 }]], undefined, undefined, @@ -827,7 +847,7 @@ describe("createAnimationsFromSequence", () => { }) test("It handles sequence with only null element gracefully", () => { - const animations = createAnimationsFromSequence( + const animations = getAnimationDefinitions( [[null as unknown as Element, { opacity: 1 }, { duration: 1 }]], undefined, undefined, @@ -838,3 +858,159 @@ describe("createAnimationsFromSequence", () => { expect(animations.size).toBe(0) }) }) + +describe("Sequence callbacks", () => { + const a = document.createElement("div") + const b = document.createElement("div") + + test("It extracts callbacks with default timing", () => { + const { callbacks, totalDuration } = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [{ onEnter: () => {} }, {}], + [b, { y: 200 }, { duration: 1 }], + ], + undefined, + undefined, + { spring } + ) + + expect(callbacks.length).toBe(1) + expect(callbacks[0].time).toBe(1) // After first animation + expect(typeof callbacks[0].onEnter).toBe("function") + expect(totalDuration).toBe(2) + }) + + test("It extracts callbacks with explicit at timing", () => { + const { callbacks } = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 2 }], + [{ onEnter: () => {} }, { at: 0.5 }], + ], + undefined, + undefined, + { spring } + ) + + expect(callbacks.length).toBe(1) + expect(callbacks[0].time).toBe(0.5) + }) + + test("It extracts callbacks with relative timing", () => { + const { callbacks } = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [{ onEnter: () => {} }, { at: "+0.5" }], + ], + undefined, + undefined, + { spring } + ) + + expect(callbacks.length).toBe(1) + expect(callbacks[0].time).toBe(1.5) // 1s (after anim) + 0.5s offset + }) + + test("It extracts callbacks with < timing", () => { + const { callbacks } = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [b, { y: 200 }, { duration: 1 }], + [{ onEnter: () => {} }, { at: "<" }], + ], + undefined, + undefined, + { spring } + ) + + expect(callbacks.length).toBe(1) + expect(callbacks[0].time).toBe(1) // Start of previous animation + }) + + test("It extracts multiple callbacks sorted by time", () => { + const onEnter1 = () => {} + const onEnter2 = () => {} + const onEnter3 = () => {} + + const { callbacks } = createAnimationsFromSequence( + [ + [{ onEnter: onEnter2 }, { at: 1 }], + [a, { x: 100 }, { duration: 2 }], + [{ onEnter: onEnter3 }, { at: 1.5 }], + [{ onEnter: onEnter1 }, { at: 0.5 }], + ], + undefined, + undefined, + { spring } + ) + + expect(callbacks.length).toBe(3) + // Should be sorted by time + expect(callbacks[0].time).toBe(0.5) + expect(callbacks[0].onEnter).toBe(onEnter1) + expect(callbacks[1].time).toBe(1) + expect(callbacks[1].onEnter).toBe(onEnter2) + expect(callbacks[2].time).toBe(1.5) + expect(callbacks[2].onEnter).toBe(onEnter3) + }) + + test("It extracts callbacks with onEnter and onLeave", () => { + const onEnter = () => {} + const onLeave = () => {} + + const { callbacks } = createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [{ onEnter, onLeave }, { at: 0.5 }], + ], + undefined, + undefined, + { spring } + ) + + expect(callbacks.length).toBe(1) + expect(callbacks[0].onEnter).toBe(onEnter) + expect(callbacks[0].onLeave).toBe(onLeave) + }) + + test("It extracts callbacks with label-based timing", () => { + const { callbacks } = createAnimationsFromSequence( + [ + "my-label", + [a, { x: 100 }, { duration: 1 }], + [{ onEnter: () => {} }, { at: "my-label" }], + ], + undefined, + undefined, + { spring } + ) + + expect(callbacks.length).toBe(1) + expect(callbacks[0].time).toBe(0) // Label was set at time 0 + }) + + test("Callbacks don't affect animation timing", () => { + const { animationDefinitions, totalDuration } = + createAnimationsFromSequence( + [ + [a, { x: 100 }, { duration: 1 }], + [{ onEnter: () => {} }, {}], + [{ onEnter: () => {} }, {}], + [{ onEnter: () => {} }, {}], + [b, { y: 200 }, { duration: 1 }], + ], + undefined, + undefined, + { spring } + ) + + // Callbacks should not add to the timeline duration + expect(totalDuration).toBe(2) + + // Animations should be positioned correctly + expect(animationDefinitions.get(a)!.transition.x.times).toEqual([0, 0.5]) + expect(animationDefinitions.get(b)!.transition.y.times).toEqual([ + 0, 0.5, 1, + ]) + }) +}) diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 07ead3c85b..0cbb7f7571 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -25,6 +25,8 @@ import { AnimationSequence, At, ResolvedAnimationDefinitions, + ResolvedSequenceCallback, + SequenceCallback, SequenceMap, SequenceOptions, ValueSequence, @@ -39,15 +41,22 @@ const defaultSegmentEasing = "easeInOut" const MAX_REPEAT = 20 +export interface CreateAnimationsResult { + animationDefinitions: ResolvedAnimationDefinitions + callbacks: ResolvedSequenceCallback[] + totalDuration: number +} + export function createAnimationsFromSequence( sequence: AnimationSequence, { defaultTransition = {}, ...sequenceTransition }: SequenceOptions = {}, scope?: AnimationScope, generators?: { [key: string]: GeneratorFactory } -): ResolvedAnimationDefinitions { +): CreateAnimationsResult { const defaultDuration = defaultTransition.duration || 0.3 const animationDefinitions: ResolvedAnimationDefinitions = new Map() const sequences = new Map() + const callbacks: ResolvedSequenceCallback[] = [] const elementCache = {} const timeLabels = new Map() @@ -77,6 +86,24 @@ export function createAnimationsFromSequence( continue } + /** + * If this is a callback segment, extract the callback and its timing + */ + if (isCallbackSegment(segment)) { + const [callback, options] = segment + const callbackTime = + options.at !== undefined + ? calcNextTime(currentTime, options.at, prevTime, timeLabels) + : currentTime + + callbacks.push({ + time: callbackTime, + onEnter: callback.onEnter, + onLeave: callback.onLeave, + }) + continue + } + let [subject, keyframes, transition = {}] = segment /** @@ -390,7 +417,10 @@ export function createAnimationsFromSequence( } }) - return animationDefinitions + // Sort callbacks by time for efficient lookup during playback + callbacks.sort((a, b) => a.time - b.time) + + return { animationDefinitions, callbacks, totalDuration } } function getSubjectSequence( @@ -428,3 +458,20 @@ const isNumber = (keyframe: unknown) => typeof keyframe === "number" const isNumberKeyframesArray = ( keyframes: UnresolvedValueKeyframe[] ): keyframes is number[] => keyframes.every(isNumber) + +/** + * Check if a segment is a callback segment: [{ onEnter?, onLeave? }, { at? }] + */ +function isCallbackSegment( + segment: unknown +): segment is [SequenceCallback, At] { + if (!Array.isArray(segment) || segment.length !== 2) return false + const [callback, options] = segment + if (typeof callback !== "object" || callback === null) return false + // It's a callback if it has onEnter or onLeave and no other animation properties + return ( + ("onEnter" in callback || "onLeave" in callback) && + !("duration" in options) && + !("ease" in options) + ) +} diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index 2797db4e4d..f65a3bcb5a 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -58,6 +58,18 @@ export type ObjectSegmentWithTransition = [ DynamicAnimationOptions & At ] +/** + * Callback to be invoked at a specific point in the sequence. + * - `onEnter`: Called when time crosses this point moving forward + * - `onLeave`: Called when time crosses this point moving backward (for scrubbing) + */ +export interface SequenceCallback { + onEnter?: VoidFunction + onLeave?: VoidFunction +} + +export type CallbackSegment = [SequenceCallback, At] + export type Segment = | ObjectSegment | ObjectSegmentWithTransition @@ -67,6 +79,7 @@ export type Segment = | MotionValueSegmentWithTransition | DOMSegment | DOMSegmentWithTransition + | CallbackSegment export type AnimationSequence = Segment[] @@ -98,3 +111,12 @@ export type ResolvedAnimationDefinitions = Map< Element | MotionValue, ResolvedAnimationDefinition > + +/** + * A callback positioned at an absolute time in the sequence + */ +export interface ResolvedSequenceCallback { + time: number + onEnter?: VoidFunction + onLeave?: VoidFunction +} diff --git a/packages/motion-dom/src/animation/SequenceCallbackAnimation.ts b/packages/motion-dom/src/animation/SequenceCallbackAnimation.ts new file mode 100644 index 0000000000..cd6302234f --- /dev/null +++ b/packages/motion-dom/src/animation/SequenceCallbackAnimation.ts @@ -0,0 +1,127 @@ +import { millisecondsToSeconds, secondsToMilliseconds } from "motion-utils" +import { AnimationPlaybackControls, TimelineWithFallback } from "./types" + +export interface SequenceCallback { + time: number + onEnter?: VoidFunction + onLeave?: VoidFunction +} + +/** + * A lightweight "animation" that fires callbacks when time crosses specific points. + * This implements just enough of AnimationPlaybackControls to work within a GroupAnimation. + * + * Overhead is minimal: + * - No rendering or DOM operations + * - Simple time comparison logic + * - Only fires callbacks when time crosses thresholds + */ +export class SequenceCallbackAnimation implements AnimationPlaybackControls { + state: AnimationPlayState = "running" + startTime: number | null = null + + private callbacks: SequenceCallback[] + private totalDuration: number + private currentTime: number = 0 + private playbackSpeed: number = 1 + private resolveFinished!: VoidFunction + private rejectFinished?: (reason?: unknown) => void + + finished: Promise + + constructor(callbacks: SequenceCallback[], totalDuration: number) { + // Callbacks should already be sorted by time + this.callbacks = callbacks + this.totalDuration = totalDuration + + this.finished = new Promise((resolve, reject) => { + this.resolveFinished = resolve + this.rejectFinished = reject + }) + } + + get time(): number { + return millisecondsToSeconds(this.currentTime) + } + + set time(newTime: number) { + const newTimeMs = secondsToMilliseconds(newTime) + this.fireCallbacks(this.currentTime, newTimeMs) + this.currentTime = newTimeMs + } + + get speed(): number { + return this.playbackSpeed + } + + set speed(newSpeed: number) { + this.playbackSpeed = newSpeed + } + + get duration(): number { + return millisecondsToSeconds(this.totalDuration) + } + + get iterationDuration(): number { + return this.duration + } + + /** + * Fire callbacks that we've crossed between prevTime and newTime. + * Handles both forward and backward scrubbing. + */ + private fireCallbacks(fromTime: number, toTime: number): void { + if (fromTime === toTime || this.callbacks.length === 0) return + + const isForward = toTime > fromTime + + for (const callback of this.callbacks) { + const callbackTimeMs = secondsToMilliseconds(callback.time) + + if (isForward) { + // Moving forward: fire onEnter when we cross the callback time + if (fromTime < callbackTimeMs && toTime >= callbackTimeMs) { + callback.onEnter?.() + } + } else { + // Moving backward: fire onLeave when we cross the callback time + if (fromTime >= callbackTimeMs && toTime < callbackTimeMs) { + callback.onLeave?.() + } + } + } + } + + play(): void { + this.state = "running" + } + + pause(): void { + this.state = "paused" + } + + stop(): void { + this.state = "idle" + } + + cancel(): void { + this.state = "idle" + this.rejectFinished?.() + } + + complete(): void { + // Fire any remaining callbacks before completion + this.fireCallbacks(this.currentTime, this.totalDuration) + this.currentTime = this.totalDuration + this.state = "finished" + this.resolveFinished() + } + + /** + * Attach to a timeline (e.g., scroll timeline). + * Returns a cleanup function. + */ + attachTimeline(timeline: TimelineWithFallback): VoidFunction { + return timeline.observe(this as unknown as Parameters[0]) + } +} diff --git a/packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts b/packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts new file mode 100644 index 0000000000..72bc9bcbe5 --- /dev/null +++ b/packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts @@ -0,0 +1,158 @@ +import { SequenceCallbackAnimation } from "../SequenceCallbackAnimation" + +describe("SequenceCallbackAnimation", () => { + test("Fires onEnter when time crosses callback point forward", () => { + const onEnter = jest.fn() + const animation = new SequenceCallbackAnimation( + [{ time: 0.5, onEnter }], + 1000 // 1 second total duration + ) + + animation.time = 0 + expect(onEnter).not.toHaveBeenCalled() + + animation.time = 0.6 // Cross the 0.5s mark + expect(onEnter).toHaveBeenCalledTimes(1) + + // Setting time again beyond the point shouldn't fire again + animation.time = 0.8 + expect(onEnter).toHaveBeenCalledTimes(1) + }) + + test("Fires onLeave when time crosses callback point backward", () => { + const onEnter = jest.fn() + const onLeave = jest.fn() + const animation = new SequenceCallbackAnimation( + [{ time: 0.5, onEnter, onLeave }], + 1000 + ) + + // Start past the callback point + animation.time = 0.8 + expect(onEnter).toHaveBeenCalledTimes(1) + + // Go backward past the callback point + animation.time = 0.3 + expect(onLeave).toHaveBeenCalledTimes(1) + }) + + test("Fires multiple callbacks in order when crossing", () => { + const calls: string[] = [] + const animation = new SequenceCallbackAnimation( + [ + { time: 0.2, onEnter: () => calls.push("a") }, + { time: 0.5, onEnter: () => calls.push("b") }, + { time: 0.8, onEnter: () => calls.push("c") }, + ], + 1000 + ) + + animation.time = 0 + animation.time = 1 // Cross all three + + expect(calls).toEqual(["a", "b", "c"]) + }) + + test("Fires callbacks in reverse order when scrubbing backward", () => { + const calls: string[] = [] + const animation = new SequenceCallbackAnimation( + [ + { time: 0.2, onLeave: () => calls.push("a") }, + { time: 0.5, onLeave: () => calls.push("b") }, + { time: 0.8, onLeave: () => calls.push("c") }, + ], + 1000 + ) + + // Start at the end + animation.time = 1 + calls.length = 0 // Clear any onEnter calls + + // Scrub back to start + animation.time = 0 + + expect(calls).toEqual(["c", "b", "a"]) + }) + + test("Does not fire when time stays on same side of callback", () => { + const onEnter = jest.fn() + const animation = new SequenceCallbackAnimation( + [{ time: 0.5, onEnter }], + 1000 + ) + + animation.time = 0.1 + animation.time = 0.3 + animation.time = 0.4 + + expect(onEnter).not.toHaveBeenCalled() + }) + + test("Reports correct duration", () => { + const animation = new SequenceCallbackAnimation([], 2000) + expect(animation.duration).toBe(2) + }) + + test("Handles complete() by firing remaining callbacks", () => { + const onEnter = jest.fn() + const animation = new SequenceCallbackAnimation( + [{ time: 0.5, onEnter }], + 1000 + ) + + animation.time = 0 + animation.complete() + + expect(onEnter).toHaveBeenCalledTimes(1) + }) + + test("Works with callbacks at time 0", () => { + const onEnter = jest.fn() + const animation = new SequenceCallbackAnimation( + [{ time: 0, onEnter }], + 1000 + ) + + // Set time just past 0 + animation.time = 0.01 + expect(onEnter).toHaveBeenCalledTimes(1) + }) + + test("Works with callbacks at end of animation", () => { + const onEnter = jest.fn() + const animation = new SequenceCallbackAnimation( + [{ time: 1, onEnter }], + 1000 + ) + + animation.time = 0.5 + animation.time = 1 + + expect(onEnter).toHaveBeenCalledTimes(1) + }) + + test("Handles empty callbacks array", () => { + const animation = new SequenceCallbackAnimation([], 1000) + + // Should not throw + animation.time = 0.5 + animation.time = 1 + + expect(animation.duration).toBe(1) + }) + + test("Playback controls work correctly", () => { + const animation = new SequenceCallbackAnimation([], 1000) + + expect(animation.state).toBe("running") + + animation.pause() + expect(animation.state).toBe("paused") + + animation.play() + expect(animation.state).toBe("running") + + animation.stop() + expect(animation.state).toBe("idle") + }) +}) diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 9ca4a3159c..44d4fe7a3a 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -5,6 +5,7 @@ export * from "./animation/JSAnimation" export * from "./animation/NativeAnimation" export * from "./animation/NativeAnimationExtended" export * from "./animation/NativeAnimationWrapper" +export * from "./animation/SequenceCallbackAnimation" export * from "./animation/types" export * from "./animation/utils/active-animations" export { calcChildStagger } from "./animation/utils/calc-child-stagger" From 1f468e6382641d868c66e12a278b58fec98da962 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 13:59:23 +0000 Subject: [PATCH 02/16] refactor: Simplify sequence callbacks using existing animation infra - Rename onEnter/onLeave to forward/backward for clarity - Remove dedicated SequenceCallbackAnimation class - Use animateSingleValue(0, 1) with onUpdate to track time crossings - Much simpler implementation with same functionality https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF --- .../src/animation/animate/sequence.ts | 53 ++++-- .../animators/waapi/animate-sequence.ts | 48 +++++- .../sequence/__tests__/index.test.ts | 48 +++--- .../src/animation/sequence/create.ts | 10 +- .../src/animation/sequence/types.ts | 12 +- .../animation/SequenceCallbackAnimation.ts | 127 -------------- .../SequenceCallbackAnimation.test.ts | 158 ------------------ packages/motion-dom/src/index.ts | 1 - 8 files changed, 116 insertions(+), 341 deletions(-) delete mode 100644 packages/motion-dom/src/animation/SequenceCallbackAnimation.ts delete mode 100644 packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index f05addd876..f008479a59 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -1,14 +1,46 @@ import { + animateSingleValue, AnimationPlaybackControlsWithThen, AnimationScope, - SequenceCallbackAnimation, spring, } from "motion-dom" -import { secondsToMilliseconds } from "motion-utils" import { createAnimationsFromSequence } from "../sequence/create" -import { AnimationSequence, SequenceOptions } from "../sequence/types" +import { + AnimationSequence, + ResolvedSequenceCallback, + SequenceOptions, +} from "../sequence/types" import { animateSubject } from "./subject" +/** + * Creates an onUpdate callback that fires sequence callbacks when time crosses their thresholds. + * Tracks previous progress to detect direction (forward/backward). + */ +function createCallbackUpdater( + callbacks: ResolvedSequenceCallback[], + totalDuration: number +) { + let prevProgress = 0 + + return (progress: number) => { + const currentTime = progress * totalDuration + + for (const callback of callbacks) { + const prevTime = prevProgress * totalDuration + + if (prevTime < callback.time && currentTime >= callback.time) { + // Crossed forward + callback.forward?.() + } else if (prevTime >= callback.time && currentTime < callback.time) { + // Crossed backward + callback.backward?.() + } + } + + prevProgress = progress + } +} + export function animateSequence( sequence: AnimationSequence, options?: SequenceOptions, @@ -23,15 +55,14 @@ export function animateSequence( animations.push(...animateSubject(subject, keyframes, transition)) }) - // Add callback animation if there are any callbacks + // Add a 0→1 animation with onUpdate to track callbacks if (callbacks.length > 0) { - const callbackAnimation = new SequenceCallbackAnimation( - callbacks, - secondsToMilliseconds(totalDuration) - ) - animations.push( - callbackAnimation as unknown as AnimationPlaybackControlsWithThen - ) + const callbackAnimation = animateSingleValue(0, 1, { + duration: totalDuration, + ease: "linear", + onUpdate: createCallbackUpdater(callbacks, totalDuration), + }) + animations.push(callbackAnimation) } return animations diff --git a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts index 4af7d14ebb..5d07b6ed45 100644 --- a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts +++ b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts @@ -1,13 +1,42 @@ import { + animateSingleValue, AnimationPlaybackControls, GroupAnimationWithThen, - SequenceCallbackAnimation, } from "motion-dom" -import { secondsToMilliseconds } from "motion-utils" import { createAnimationsFromSequence } from "../../sequence/create" -import { AnimationSequence, SequenceOptions } from "../../sequence/types" +import { + AnimationSequence, + ResolvedSequenceCallback, + SequenceOptions, +} from "../../sequence/types" import { animateElements } from "./animate-elements" +/** + * Creates an onUpdate callback that fires sequence callbacks when time crosses their thresholds. + */ +function createCallbackUpdater( + callbacks: ResolvedSequenceCallback[], + totalDuration: number +) { + let prevProgress = 0 + + return (progress: number) => { + const currentTime = progress * totalDuration + + for (const callback of callbacks) { + const prevTime = prevProgress * totalDuration + + if (prevTime < callback.time && currentTime >= callback.time) { + callback.forward?.() + } else if (prevTime >= callback.time && currentTime < callback.time) { + callback.backward?.() + } + } + + prevProgress = progress + } +} + export function animateSequence( definition: AnimationSequence, options?: SequenceOptions @@ -23,13 +52,14 @@ export function animateSequence( } ) - // Add callback animation if there are any callbacks + // Add a 0→1 animation with onUpdate to track callbacks if (callbacks.length > 0) { - const callbackAnimation = new SequenceCallbackAnimation( - callbacks, - secondsToMilliseconds(totalDuration) - ) - animations.push(callbackAnimation as unknown as AnimationPlaybackControls) + const callbackAnimation = animateSingleValue(0, 1, { + duration: totalDuration, + ease: "linear", + onUpdate: createCallbackUpdater(callbacks, totalDuration), + }) + animations.push(callbackAnimation) } return new GroupAnimationWithThen(animations) 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 92c08e7000..59ee7a9c6b 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -867,7 +867,7 @@ describe("Sequence callbacks", () => { const { callbacks, totalDuration } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ onEnter: () => {} }, {}], + [{ forward: () => {} }, {}], [b, { y: 200 }, { duration: 1 }], ], undefined, @@ -877,7 +877,7 @@ describe("Sequence callbacks", () => { expect(callbacks.length).toBe(1) expect(callbacks[0].time).toBe(1) // After first animation - expect(typeof callbacks[0].onEnter).toBe("function") + expect(typeof callbacks[0].forward).toBe("function") expect(totalDuration).toBe(2) }) @@ -885,7 +885,7 @@ describe("Sequence callbacks", () => { const { callbacks } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 2 }], - [{ onEnter: () => {} }, { at: 0.5 }], + [{ forward: () => {} }, { at: 0.5 }], ], undefined, undefined, @@ -900,7 +900,7 @@ describe("Sequence callbacks", () => { const { callbacks } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ onEnter: () => {} }, { at: "+0.5" }], + [{ forward: () => {} }, { at: "+0.5" }], ], undefined, undefined, @@ -916,7 +916,7 @@ describe("Sequence callbacks", () => { [ [a, { x: 100 }, { duration: 1 }], [b, { y: 200 }, { duration: 1 }], - [{ onEnter: () => {} }, { at: "<" }], + [{ forward: () => {} }, { at: "<" }], ], undefined, undefined, @@ -928,16 +928,16 @@ describe("Sequence callbacks", () => { }) test("It extracts multiple callbacks sorted by time", () => { - const onEnter1 = () => {} - const onEnter2 = () => {} - const onEnter3 = () => {} + const forward1 = () => {} + const forward2 = () => {} + const forward3 = () => {} const { callbacks } = createAnimationsFromSequence( [ - [{ onEnter: onEnter2 }, { at: 1 }], + [{ forward: forward2 }, { at: 1 }], [a, { x: 100 }, { duration: 2 }], - [{ onEnter: onEnter3 }, { at: 1.5 }], - [{ onEnter: onEnter1 }, { at: 0.5 }], + [{ forward: forward3 }, { at: 1.5 }], + [{ forward: forward1 }, { at: 0.5 }], ], undefined, undefined, @@ -947,21 +947,21 @@ describe("Sequence callbacks", () => { expect(callbacks.length).toBe(3) // Should be sorted by time expect(callbacks[0].time).toBe(0.5) - expect(callbacks[0].onEnter).toBe(onEnter1) + expect(callbacks[0].forward).toBe(forward1) expect(callbacks[1].time).toBe(1) - expect(callbacks[1].onEnter).toBe(onEnter2) + expect(callbacks[1].forward).toBe(forward2) expect(callbacks[2].time).toBe(1.5) - expect(callbacks[2].onEnter).toBe(onEnter3) + expect(callbacks[2].forward).toBe(forward3) }) - test("It extracts callbacks with onEnter and onLeave", () => { - const onEnter = () => {} - const onLeave = () => {} + test("It extracts callbacks with forward and backward", () => { + const forward = () => {} + const backward = () => {} const { callbacks } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ onEnter, onLeave }, { at: 0.5 }], + [{ forward, backward }, { at: 0.5 }], ], undefined, undefined, @@ -969,8 +969,8 @@ describe("Sequence callbacks", () => { ) expect(callbacks.length).toBe(1) - expect(callbacks[0].onEnter).toBe(onEnter) - expect(callbacks[0].onLeave).toBe(onLeave) + expect(callbacks[0].forward).toBe(forward) + expect(callbacks[0].backward).toBe(backward) }) test("It extracts callbacks with label-based timing", () => { @@ -978,7 +978,7 @@ describe("Sequence callbacks", () => { [ "my-label", [a, { x: 100 }, { duration: 1 }], - [{ onEnter: () => {} }, { at: "my-label" }], + [{ forward: () => {} }, { at: "my-label" }], ], undefined, undefined, @@ -994,9 +994,9 @@ describe("Sequence callbacks", () => { createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ onEnter: () => {} }, {}], - [{ onEnter: () => {} }, {}], - [{ onEnter: () => {} }, {}], + [{ forward: () => {} }, {}], + [{ forward: () => {} }, {}], + [{ forward: () => {} }, {}], [b, { y: 200 }, { duration: 1 }], ], undefined, diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 0cbb7f7571..0615f172e0 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -98,8 +98,8 @@ export function createAnimationsFromSequence( callbacks.push({ time: callbackTime, - onEnter: callback.onEnter, - onLeave: callback.onLeave, + forward: callback.forward, + backward: callback.backward, }) continue } @@ -460,7 +460,7 @@ const isNumberKeyframesArray = ( ): keyframes is number[] => keyframes.every(isNumber) /** - * Check if a segment is a callback segment: [{ onEnter?, onLeave? }, { at? }] + * Check if a segment is a callback segment: [{ forward?, backward? }, { at? }] */ function isCallbackSegment( segment: unknown @@ -468,9 +468,9 @@ function isCallbackSegment( if (!Array.isArray(segment) || segment.length !== 2) return false const [callback, options] = segment if (typeof callback !== "object" || callback === null) return false - // It's a callback if it has onEnter or onLeave and no other animation properties + // It's a callback if it has forward or backward and no other animation properties return ( - ("onEnter" in callback || "onLeave" in callback) && + ("forward" in callback || "backward" in callback) && !("duration" in options) && !("ease" in options) ) diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index f65a3bcb5a..c245b6d400 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -60,12 +60,12 @@ export type ObjectSegmentWithTransition = [ /** * Callback to be invoked at a specific point in the sequence. - * - `onEnter`: Called when time crosses this point moving forward - * - `onLeave`: Called when time crosses this point moving backward (for scrubbing) + * - `forward`: Called when time crosses this point moving forward + * - `backward`: Called when time crosses this point moving backward (for scrubbing) */ export interface SequenceCallback { - onEnter?: VoidFunction - onLeave?: VoidFunction + forward?: VoidFunction + backward?: VoidFunction } export type CallbackSegment = [SequenceCallback, At] @@ -117,6 +117,6 @@ export type ResolvedAnimationDefinitions = Map< */ export interface ResolvedSequenceCallback { time: number - onEnter?: VoidFunction - onLeave?: VoidFunction + forward?: VoidFunction + backward?: VoidFunction } diff --git a/packages/motion-dom/src/animation/SequenceCallbackAnimation.ts b/packages/motion-dom/src/animation/SequenceCallbackAnimation.ts deleted file mode 100644 index cd6302234f..0000000000 --- a/packages/motion-dom/src/animation/SequenceCallbackAnimation.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { millisecondsToSeconds, secondsToMilliseconds } from "motion-utils" -import { AnimationPlaybackControls, TimelineWithFallback } from "./types" - -export interface SequenceCallback { - time: number - onEnter?: VoidFunction - onLeave?: VoidFunction -} - -/** - * A lightweight "animation" that fires callbacks when time crosses specific points. - * This implements just enough of AnimationPlaybackControls to work within a GroupAnimation. - * - * Overhead is minimal: - * - No rendering or DOM operations - * - Simple time comparison logic - * - Only fires callbacks when time crosses thresholds - */ -export class SequenceCallbackAnimation implements AnimationPlaybackControls { - state: AnimationPlayState = "running" - startTime: number | null = null - - private callbacks: SequenceCallback[] - private totalDuration: number - private currentTime: number = 0 - private playbackSpeed: number = 1 - private resolveFinished!: VoidFunction - private rejectFinished?: (reason?: unknown) => void - - finished: Promise - - constructor(callbacks: SequenceCallback[], totalDuration: number) { - // Callbacks should already be sorted by time - this.callbacks = callbacks - this.totalDuration = totalDuration - - this.finished = new Promise((resolve, reject) => { - this.resolveFinished = resolve - this.rejectFinished = reject - }) - } - - get time(): number { - return millisecondsToSeconds(this.currentTime) - } - - set time(newTime: number) { - const newTimeMs = secondsToMilliseconds(newTime) - this.fireCallbacks(this.currentTime, newTimeMs) - this.currentTime = newTimeMs - } - - get speed(): number { - return this.playbackSpeed - } - - set speed(newSpeed: number) { - this.playbackSpeed = newSpeed - } - - get duration(): number { - return millisecondsToSeconds(this.totalDuration) - } - - get iterationDuration(): number { - return this.duration - } - - /** - * Fire callbacks that we've crossed between prevTime and newTime. - * Handles both forward and backward scrubbing. - */ - private fireCallbacks(fromTime: number, toTime: number): void { - if (fromTime === toTime || this.callbacks.length === 0) return - - const isForward = toTime > fromTime - - for (const callback of this.callbacks) { - const callbackTimeMs = secondsToMilliseconds(callback.time) - - if (isForward) { - // Moving forward: fire onEnter when we cross the callback time - if (fromTime < callbackTimeMs && toTime >= callbackTimeMs) { - callback.onEnter?.() - } - } else { - // Moving backward: fire onLeave when we cross the callback time - if (fromTime >= callbackTimeMs && toTime < callbackTimeMs) { - callback.onLeave?.() - } - } - } - } - - play(): void { - this.state = "running" - } - - pause(): void { - this.state = "paused" - } - - stop(): void { - this.state = "idle" - } - - cancel(): void { - this.state = "idle" - this.rejectFinished?.() - } - - complete(): void { - // Fire any remaining callbacks before completion - this.fireCallbacks(this.currentTime, this.totalDuration) - this.currentTime = this.totalDuration - this.state = "finished" - this.resolveFinished() - } - - /** - * Attach to a timeline (e.g., scroll timeline). - * Returns a cleanup function. - */ - attachTimeline(timeline: TimelineWithFallback): VoidFunction { - return timeline.observe(this as unknown as Parameters[0]) - } -} diff --git a/packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts b/packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts deleted file mode 100644 index 72bc9bcbe5..0000000000 --- a/packages/motion-dom/src/animation/__tests__/SequenceCallbackAnimation.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { SequenceCallbackAnimation } from "../SequenceCallbackAnimation" - -describe("SequenceCallbackAnimation", () => { - test("Fires onEnter when time crosses callback point forward", () => { - const onEnter = jest.fn() - const animation = new SequenceCallbackAnimation( - [{ time: 0.5, onEnter }], - 1000 // 1 second total duration - ) - - animation.time = 0 - expect(onEnter).not.toHaveBeenCalled() - - animation.time = 0.6 // Cross the 0.5s mark - expect(onEnter).toHaveBeenCalledTimes(1) - - // Setting time again beyond the point shouldn't fire again - animation.time = 0.8 - expect(onEnter).toHaveBeenCalledTimes(1) - }) - - test("Fires onLeave when time crosses callback point backward", () => { - const onEnter = jest.fn() - const onLeave = jest.fn() - const animation = new SequenceCallbackAnimation( - [{ time: 0.5, onEnter, onLeave }], - 1000 - ) - - // Start past the callback point - animation.time = 0.8 - expect(onEnter).toHaveBeenCalledTimes(1) - - // Go backward past the callback point - animation.time = 0.3 - expect(onLeave).toHaveBeenCalledTimes(1) - }) - - test("Fires multiple callbacks in order when crossing", () => { - const calls: string[] = [] - const animation = new SequenceCallbackAnimation( - [ - { time: 0.2, onEnter: () => calls.push("a") }, - { time: 0.5, onEnter: () => calls.push("b") }, - { time: 0.8, onEnter: () => calls.push("c") }, - ], - 1000 - ) - - animation.time = 0 - animation.time = 1 // Cross all three - - expect(calls).toEqual(["a", "b", "c"]) - }) - - test("Fires callbacks in reverse order when scrubbing backward", () => { - const calls: string[] = [] - const animation = new SequenceCallbackAnimation( - [ - { time: 0.2, onLeave: () => calls.push("a") }, - { time: 0.5, onLeave: () => calls.push("b") }, - { time: 0.8, onLeave: () => calls.push("c") }, - ], - 1000 - ) - - // Start at the end - animation.time = 1 - calls.length = 0 // Clear any onEnter calls - - // Scrub back to start - animation.time = 0 - - expect(calls).toEqual(["c", "b", "a"]) - }) - - test("Does not fire when time stays on same side of callback", () => { - const onEnter = jest.fn() - const animation = new SequenceCallbackAnimation( - [{ time: 0.5, onEnter }], - 1000 - ) - - animation.time = 0.1 - animation.time = 0.3 - animation.time = 0.4 - - expect(onEnter).not.toHaveBeenCalled() - }) - - test("Reports correct duration", () => { - const animation = new SequenceCallbackAnimation([], 2000) - expect(animation.duration).toBe(2) - }) - - test("Handles complete() by firing remaining callbacks", () => { - const onEnter = jest.fn() - const animation = new SequenceCallbackAnimation( - [{ time: 0.5, onEnter }], - 1000 - ) - - animation.time = 0 - animation.complete() - - expect(onEnter).toHaveBeenCalledTimes(1) - }) - - test("Works with callbacks at time 0", () => { - const onEnter = jest.fn() - const animation = new SequenceCallbackAnimation( - [{ time: 0, onEnter }], - 1000 - ) - - // Set time just past 0 - animation.time = 0.01 - expect(onEnter).toHaveBeenCalledTimes(1) - }) - - test("Works with callbacks at end of animation", () => { - const onEnter = jest.fn() - const animation = new SequenceCallbackAnimation( - [{ time: 1, onEnter }], - 1000 - ) - - animation.time = 0.5 - animation.time = 1 - - expect(onEnter).toHaveBeenCalledTimes(1) - }) - - test("Handles empty callbacks array", () => { - const animation = new SequenceCallbackAnimation([], 1000) - - // Should not throw - animation.time = 0.5 - animation.time = 1 - - expect(animation.duration).toBe(1) - }) - - test("Playback controls work correctly", () => { - const animation = new SequenceCallbackAnimation([], 1000) - - expect(animation.state).toBe("running") - - animation.pause() - expect(animation.state).toBe("paused") - - animation.play() - expect(animation.state).toBe("running") - - animation.stop() - expect(animation.state).toBe("idle") - }) -}) diff --git a/packages/motion-dom/src/index.ts b/packages/motion-dom/src/index.ts index 44d4fe7a3a..9ca4a3159c 100644 --- a/packages/motion-dom/src/index.ts +++ b/packages/motion-dom/src/index.ts @@ -5,7 +5,6 @@ export * from "./animation/JSAnimation" export * from "./animation/NativeAnimation" export * from "./animation/NativeAnimationExtended" export * from "./animation/NativeAnimationWrapper" -export * from "./animation/SequenceCallbackAnimation" export * from "./animation/types" export * from "./animation/utils/active-animations" export { calcChildStagger } from "./animation/utils/calc-child-stagger" From 07dcda0d402fb4b8ba9282bfdc449dba9a96ff7b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 10:12:41 +0000 Subject: [PATCH 03/16] fix: Remove callback support from WAAPI animate-sequence The WAAPI path should stay minimal and not pull in animateSingleValue. https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF --- .../animators/waapi/animate-sequence.ts | 54 +++---------------- 1 file changed, 6 insertions(+), 48 deletions(-) diff --git a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts index 5d07b6ed45..13240df191 100644 --- a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts +++ b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts @@ -1,50 +1,18 @@ -import { - animateSingleValue, - AnimationPlaybackControls, - GroupAnimationWithThen, -} from "motion-dom" +import { AnimationPlaybackControls, GroupAnimationWithThen } from "motion-dom" import { createAnimationsFromSequence } from "../../sequence/create" -import { - AnimationSequence, - ResolvedSequenceCallback, - SequenceOptions, -} from "../../sequence/types" +import { AnimationSequence, SequenceOptions } from "../../sequence/types" import { animateElements } from "./animate-elements" -/** - * Creates an onUpdate callback that fires sequence callbacks when time crosses their thresholds. - */ -function createCallbackUpdater( - callbacks: ResolvedSequenceCallback[], - totalDuration: number -) { - let prevProgress = 0 - - return (progress: number) => { - const currentTime = progress * totalDuration - - for (const callback of callbacks) { - const prevTime = prevProgress * totalDuration - - if (prevTime < callback.time && currentTime >= callback.time) { - callback.forward?.() - } else if (prevTime >= callback.time && currentTime < callback.time) { - callback.backward?.() - } - } - - prevProgress = progress - } -} - export function animateSequence( definition: AnimationSequence, options?: SequenceOptions ) { const animations: AnimationPlaybackControls[] = [] - const { animationDefinitions, callbacks, totalDuration } = - createAnimationsFromSequence(definition, options) + const { animationDefinitions } = createAnimationsFromSequence( + definition, + options + ) animationDefinitions.forEach( ({ keyframes, transition }, element: Element) => { @@ -52,15 +20,5 @@ export function animateSequence( } ) - // Add a 0→1 animation with onUpdate to track callbacks - if (callbacks.length > 0) { - const callbackAnimation = animateSingleValue(0, 1, { - duration: totalDuration, - ease: "linear", - onUpdate: createCallbackUpdater(callbacks, totalDuration), - }) - animations.push(callbackAnimation) - } - return new GroupAnimationWithThen(animations) } From deb2cf3348d3fd059540c39cd4bd252cbf9edc96 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 10:13:54 +0000 Subject: [PATCH 04/16] refactor: Remove unnecessary getAnimationDefinitions wrapper in tests Just destructure animationDefinitions directly from createAnimationsFromSequence instead of wrapping it. https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF --- .../sequence/__tests__/index.test.ts | 94 ++++++++----------- 1 file changed, 37 insertions(+), 57 deletions(-) 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 59ee7a9c6b..6fbf0f185f 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -1,26 +1,6 @@ import { motionValue, spring, stagger } from "motion-dom" import { Easing } from "motion-utils" import { createAnimationsFromSequence } from "../create" -import { AnimationSequence, SequenceOptions } from "../types" - -/** - * Helper to maintain backward compatibility with tests. - * Extracts just the animationDefinitions map from the new return structure. - */ -function getAnimationDefinitions( - sequence: AnimationSequence, - options?: SequenceOptions, - scope?: undefined, - generators?: { spring: typeof spring } -) { - const result = createAnimationsFromSequence( - sequence, - options, - scope, - generators - ) - return result.animationDefinitions -} describe("createAnimationsFromSequence", () => { const a = document.createElement("div") @@ -29,7 +9,7 @@ describe("createAnimationsFromSequence", () => { const value = motionValue(0) test("It creates a single animation", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -54,7 +34,7 @@ describe("createAnimationsFromSequence", () => { }) test("It orders grouped keyframes correctly", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }], [a, { x: [200, 300] }], @@ -68,7 +48,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates a single animation with defaults", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[a, { opacity: 1 }, { duration: 1 }]], undefined, undefined, @@ -84,7 +64,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates a single animation with defaults - 2", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -109,7 +89,7 @@ describe("createAnimationsFromSequence", () => { }) test("It assigns the correct easing to the correct keyframes", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 1 }, { duration: 1, ease: "circIn" }], [a, { x: 2, opacity: 0 }, { duration: 1, ease: "backInOut" }], @@ -135,7 +115,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sequences one animation after another", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -179,7 +159,7 @@ describe("createAnimationsFromSequence", () => { }) test("It accepts motion values", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[value, 100, { duration: 0.5 }]], undefined, undefined, @@ -195,7 +175,7 @@ describe("createAnimationsFromSequence", () => { }) test("It accepts motion values keyframes", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[value, [50, 100], { duration: 0.5 }]], undefined, undefined, @@ -211,7 +191,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds relative time to another animation", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "+0.5" }], @@ -237,7 +217,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds moves the playhead back to the previous animation", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "<" }], @@ -263,7 +243,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds subtracts time to another animation", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "-1" }], @@ -289,7 +269,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets another animation at a specific time", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: 1.5 }], @@ -315,7 +295,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets labels from strings", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], "my label", @@ -350,7 +330,7 @@ describe("createAnimationsFromSequence", () => { }) test("Can set label as first item in sequence", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ "my label", [a, { opacity: 0 }, { duration: 1 }], @@ -377,7 +357,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets annotated labels with absolute at times", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], { name: "my label", at: 0 }, @@ -412,7 +392,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets annotated labels with relative at times", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], { name: "my label", at: "-1" }, @@ -447,7 +427,7 @@ describe("createAnimationsFromSequence", () => { }) test("It advances time by the maximum defined in individual value options", () => { - const animations = getAnimationDefinitions([ + const { animationDefinitions: animations } = createAnimationsFromSequence([ [a, { x: 1, y: 1 }, { duration: 1, y: { duration: 2 } }], [b, { y: 1 }, { duration: 0.5 }], ]) @@ -457,7 +437,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates multiple animations for multiple targets", () => { - const animations = getAnimationDefinitions([[[a, b, c], { x: 1 }]]) + const { animationDefinitions: animations } = createAnimationsFromSequence([[[a, b, c], { x: 1 }]]) expect(animations.get(a)).toBeTruthy() expect(animations.get(b)).toBeTruthy() @@ -465,7 +445,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates multiple animations, staggered", () => { - const animations = getAnimationDefinitions([ + const { animationDefinitions: animations } = createAnimationsFromSequence([ [[a, b, c], { x: 1 }, { delay: stagger(1), duration: 1 }], [a, { opacity: 1 }, { duration: 1 }], ]) @@ -499,7 +479,7 @@ describe("createAnimationsFromSequence", () => { }) test("It scales the whole animation based on the provided duration", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -519,7 +499,7 @@ describe("createAnimationsFromSequence", () => { }) test("It passes timeline options to children", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -545,7 +525,7 @@ describe("createAnimationsFromSequence", () => { }) test("It passes default options to children", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[a, { opacity: 1 }, { times: [0, 1] }]], { defaultTransition: { duration: 2, ease: "easeInOut" } } ) @@ -559,7 +539,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly passes easing cubic bezier array to children", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[a, { opacity: 1 }, { times: [0, 1] }]], { defaultTransition: { duration: 2, ease: [0, 1, 2, 3] } } ) @@ -576,7 +556,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds spring as duration-based easing when only one keyframe defined", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 200 }, { duration: 1 }], [a, { x: 0 }, { duration: 1, type: "spring", bounce: 0 }], @@ -597,7 +577,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds spring as duration-based easing when only one keyframe defined", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[a, { x: [0, 100] }, { type: "spring" }]], undefined, undefined, @@ -612,7 +592,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds springs as duration-based simulation when two keyframes defined", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 200 }, { duration: 1, ease: "linear" }], [ @@ -637,7 +617,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly adds type: spring to timeline with simulated spring", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 200 }, { duration: 1 }], [ @@ -662,7 +642,7 @@ describe("createAnimationsFromSequence", () => { }) test("Does not include type: spring in transition when spring is converted to easing via defaultTransition", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: 0 }, { duration: 0 }], [a, { x: 1.12 }], @@ -687,7 +667,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly repeats keyframes once", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[a, { x: [0, 100] }, { duration: 1, repeat: 1, ease: "linear" }]], undefined, undefined, @@ -702,7 +682,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly repeats easing", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -730,7 +710,7 @@ describe("createAnimationsFromSequence", () => { }) test("Repeating a segment correctly places the next segment at the end", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { x: [0, 100] }, { duration: 1, repeat: 1 }], [a, { y: [0, 100] }, { duration: 2 }], @@ -751,7 +731,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly adds repeatDelay between repeated keyframes", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -771,7 +751,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly mirrors repeated keyframes", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -793,7 +773,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly reverses repeated keyframes", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [ a, @@ -815,7 +795,7 @@ describe("createAnimationsFromSequence", () => { }) test("It skips null elements in sequence", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [ [a, { opacity: 1 }, { duration: 1 }], [null as unknown as Element, { opacity: 0.5 }, { duration: 1 }], @@ -833,7 +813,7 @@ describe("createAnimationsFromSequence", () => { }) test("It filters null elements from array of targets", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[[a, null as unknown as Element, b], { x: 100 }, { duration: 1 }]], undefined, undefined, @@ -847,7 +827,7 @@ describe("createAnimationsFromSequence", () => { }) test("It handles sequence with only null element gracefully", () => { - const animations = getAnimationDefinitions( + const { animationDefinitions: animations } = createAnimationsFromSequence( [[null as unknown as Element, { opacity: 1 }, { duration: 1 }]], undefined, undefined, From 1179584ad335716aaca9a4fc16d8490488bd1225 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 10:17:39 +0000 Subject: [PATCH 05/16] refactor: Restore original createAnimationsFromSequence signature Return ResolvedAnimationDefinitions directly instead of a wrapper object. Callbacks are passed via an optional out-parameter that only animateSequence uses. WAAPI path and tests are unaffected. https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF --- .../src/animation/animate/sequence.ts | 23 +- .../animators/waapi/animate-sequence.ts | 7 +- .../sequence/__tests__/index.test.ts | 216 ++++-------------- .../src/animation/sequence/create.ts | 48 ++-- 4 files changed, 94 insertions(+), 200 deletions(-) diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index f008479a59..2e3a223596 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -29,10 +29,8 @@ function createCallbackUpdater( const prevTime = prevProgress * totalDuration if (prevTime < callback.time && currentTime >= callback.time) { - // Crossed forward callback.forward?.() } else if (prevTime >= callback.time && currentTime < callback.time) { - // Crossed backward callback.backward?.() } } @@ -47,16 +45,31 @@ export function animateSequence( scope?: AnimationScope ) { const animations: AnimationPlaybackControlsWithThen[] = [] + const callbacks: ResolvedSequenceCallback[] = [] - const { animationDefinitions, callbacks, totalDuration } = - createAnimationsFromSequence(sequence, options, scope, { spring }) + const animationDefinitions = createAnimationsFromSequence( + sequence, + options, + scope, + { spring }, + callbacks + ) animationDefinitions.forEach(({ keyframes, transition }, subject) => { animations.push(...animateSubject(subject, keyframes, transition)) }) - // Add a 0→1 animation with onUpdate to track callbacks if (callbacks.length > 0) { + /** + * Read totalDuration from the first animation's transition, + * since all animations in a sequence share the same duration. + */ + const firstTransition = animationDefinitions.values().next().value + ?.transition + const totalDuration = firstTransition + ? (Object.values(firstTransition)[0] as any)?.duration ?? 0 + : 0 + const callbackAnimation = animateSingleValue(0, 1, { duration: totalDuration, ease: "linear", diff --git a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts index 13240df191..a4dd43a43f 100644 --- a/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts +++ b/packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts @@ -9,12 +9,7 @@ export function animateSequence( ) { const animations: AnimationPlaybackControls[] = [] - const { animationDefinitions } = createAnimationsFromSequence( - definition, - options - ) - - animationDefinitions.forEach( + createAnimationsFromSequence(definition, options).forEach( ({ keyframes, transition }, element: Element) => { animations.push(...animateElements(element, keyframes, transition)) } 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 6fbf0f185f..4b736d33d5 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -9,7 +9,7 @@ describe("createAnimationsFromSequence", () => { const value = motionValue(0) test("It creates a single animation", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -34,7 +34,7 @@ describe("createAnimationsFromSequence", () => { }) test("It orders grouped keyframes correctly", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }], [a, { x: [200, 300] }], @@ -48,7 +48,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates a single animation with defaults", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[a, { opacity: 1 }, { duration: 1 }]], undefined, undefined, @@ -64,7 +64,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates a single animation with defaults - 2", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -89,7 +89,7 @@ describe("createAnimationsFromSequence", () => { }) test("It assigns the correct easing to the correct keyframes", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 1 }, { duration: 1, ease: "circIn" }], [a, { x: 2, opacity: 0 }, { duration: 1, ease: "backInOut" }], @@ -115,7 +115,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sequences one animation after another", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -159,7 +159,7 @@ describe("createAnimationsFromSequence", () => { }) test("It accepts motion values", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[value, 100, { duration: 0.5 }]], undefined, undefined, @@ -175,7 +175,7 @@ describe("createAnimationsFromSequence", () => { }) test("It accepts motion values keyframes", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[value, [50, 100], { duration: 0.5 }]], undefined, undefined, @@ -191,7 +191,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds relative time to another animation", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "+0.5" }], @@ -217,7 +217,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds moves the playhead back to the previous animation", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "<" }], @@ -243,7 +243,7 @@ describe("createAnimationsFromSequence", () => { }) test("It adds subtracts time to another animation", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: "-1" }], @@ -269,7 +269,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets another animation at a specific time", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [b, { y: 500 }, { duration: 0.5, at: 1.5 }], @@ -295,7 +295,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets labels from strings", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], "my label", @@ -330,7 +330,7 @@ describe("createAnimationsFromSequence", () => { }) test("Can set label as first item in sequence", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ "my label", [a, { opacity: 0 }, { duration: 1 }], @@ -357,7 +357,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets annotated labels with absolute at times", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], { name: "my label", at: 0 }, @@ -392,7 +392,7 @@ describe("createAnimationsFromSequence", () => { }) test("It sets annotated labels with relative at times", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], { name: "my label", at: "-1" }, @@ -427,7 +427,7 @@ describe("createAnimationsFromSequence", () => { }) test("It advances time by the maximum defined in individual value options", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence([ + const animations = createAnimationsFromSequence([ [a, { x: 1, y: 1 }, { duration: 1, y: { duration: 2 } }], [b, { y: 1 }, { duration: 0.5 }], ]) @@ -437,7 +437,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates multiple animations for multiple targets", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence([[[a, b, c], { x: 1 }]]) + const animations = createAnimationsFromSequence([[[a, b, c], { x: 1 }]]) expect(animations.get(a)).toBeTruthy() expect(animations.get(b)).toBeTruthy() @@ -445,7 +445,7 @@ describe("createAnimationsFromSequence", () => { }) test("It creates multiple animations, staggered", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence([ + const animations = createAnimationsFromSequence([ [[a, b, c], { x: 1 }, { delay: stagger(1), duration: 1 }], [a, { opacity: 1 }, { duration: 1 }], ]) @@ -479,7 +479,7 @@ describe("createAnimationsFromSequence", () => { }) test("It scales the whole animation based on the provided duration", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -499,7 +499,7 @@ describe("createAnimationsFromSequence", () => { }) test("It passes timeline options to children", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -525,7 +525,7 @@ describe("createAnimationsFromSequence", () => { }) test("It passes default options to children", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[a, { opacity: 1 }, { times: [0, 1] }]], { defaultTransition: { duration: 2, ease: "easeInOut" } } ) @@ -539,7 +539,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly passes easing cubic bezier array to children", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[a, { opacity: 1 }, { times: [0, 1] }]], { defaultTransition: { duration: 2, ease: [0, 1, 2, 3] } } ) @@ -556,7 +556,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds spring as duration-based easing when only one keyframe defined", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 200 }, { duration: 1 }], [a, { x: 0 }, { duration: 1, type: "spring", bounce: 0 }], @@ -577,7 +577,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds spring as duration-based easing when only one keyframe defined", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[a, { x: [0, 100] }, { type: "spring" }]], undefined, undefined, @@ -592,7 +592,7 @@ describe("createAnimationsFromSequence", () => { }) test("Adds springs as duration-based simulation when two keyframes defined", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 200 }, { duration: 1, ease: "linear" }], [ @@ -617,7 +617,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly adds type: spring to timeline with simulated spring", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 200 }, { duration: 1 }], [ @@ -642,7 +642,7 @@ describe("createAnimationsFromSequence", () => { }) test("Does not include type: spring in transition when spring is converted to easing via defaultTransition", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: 0 }, { duration: 0 }], [a, { x: 1.12 }], @@ -667,7 +667,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly repeats keyframes once", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[a, { x: [0, 100] }, { duration: 1, repeat: 1, ease: "linear" }]], undefined, undefined, @@ -682,7 +682,7 @@ describe("createAnimationsFromSequence", () => { }) test("It correctly repeats easing", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -710,7 +710,7 @@ describe("createAnimationsFromSequence", () => { }) test("Repeating a segment correctly places the next segment at the end", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { x: [0, 100] }, { duration: 1, repeat: 1 }], [a, { y: [0, 100] }, { duration: 2 }], @@ -731,7 +731,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly adds repeatDelay between repeated keyframes", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -751,7 +751,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly mirrors repeated keyframes", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -773,7 +773,7 @@ describe("createAnimationsFromSequence", () => { }) test.skip("It correctly reverses repeated keyframes", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [ a, @@ -795,7 +795,7 @@ describe("createAnimationsFromSequence", () => { }) test("It skips null elements in sequence", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [ [a, { opacity: 1 }, { duration: 1 }], [null as unknown as Element, { opacity: 0.5 }, { duration: 1 }], @@ -813,7 +813,7 @@ describe("createAnimationsFromSequence", () => { }) test("It filters null elements from array of targets", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[[a, null as unknown as Element, b], { x: 100 }, { duration: 1 }]], undefined, undefined, @@ -827,7 +827,7 @@ describe("createAnimationsFromSequence", () => { }) test("It handles sequence with only null element gracefully", () => { - const { animationDefinitions: animations } = createAnimationsFromSequence( + const animations = createAnimationsFromSequence( [[null as unknown as Element, { opacity: 1 }, { duration: 1 }]], undefined, undefined, @@ -843,11 +843,13 @@ describe("Sequence callbacks", () => { const a = document.createElement("div") const b = document.createElement("div") - test("It extracts callbacks with default timing", () => { - const { callbacks, totalDuration } = createAnimationsFromSequence( + test("Callbacks don't affect animation timing", () => { + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], [{ forward: () => {} }, {}], + [{ forward: () => {} }, {}], + [{ forward: () => {} }, {}], [b, { y: 200 }, { duration: 1 }], ], undefined, @@ -855,142 +857,24 @@ describe("Sequence callbacks", () => { { spring } ) - expect(callbacks.length).toBe(1) - expect(callbacks[0].time).toBe(1) // After first animation - expect(typeof callbacks[0].forward).toBe("function") - expect(totalDuration).toBe(2) - }) - - test("It extracts callbacks with explicit at timing", () => { - const { callbacks } = createAnimationsFromSequence( - [ - [a, { x: 100 }, { duration: 2 }], - [{ forward: () => {} }, { at: 0.5 }], - ], - undefined, - undefined, - { spring } - ) - - expect(callbacks.length).toBe(1) - expect(callbacks[0].time).toBe(0.5) - }) - - test("It extracts callbacks with relative timing", () => { - const { callbacks } = createAnimationsFromSequence( - [ - [a, { x: 100 }, { duration: 1 }], - [{ forward: () => {} }, { at: "+0.5" }], - ], - undefined, - undefined, - { spring } - ) - - expect(callbacks.length).toBe(1) - expect(callbacks[0].time).toBe(1.5) // 1s (after anim) + 0.5s offset - }) - - test("It extracts callbacks with < timing", () => { - const { callbacks } = createAnimationsFromSequence( - [ - [a, { x: 100 }, { duration: 1 }], - [b, { y: 200 }, { duration: 1 }], - [{ forward: () => {} }, { at: "<" }], - ], - undefined, - undefined, - { spring } - ) - - expect(callbacks.length).toBe(1) - expect(callbacks[0].time).toBe(1) // Start of previous animation - }) - - test("It extracts multiple callbacks sorted by time", () => { - const forward1 = () => {} - const forward2 = () => {} - const forward3 = () => {} - - const { callbacks } = createAnimationsFromSequence( - [ - [{ forward: forward2 }, { at: 1 }], - [a, { x: 100 }, { duration: 2 }], - [{ forward: forward3 }, { at: 1.5 }], - [{ forward: forward1 }, { at: 0.5 }], - ], - undefined, - undefined, - { spring } - ) - - expect(callbacks.length).toBe(3) - // Should be sorted by time - expect(callbacks[0].time).toBe(0.5) - expect(callbacks[0].forward).toBe(forward1) - expect(callbacks[1].time).toBe(1) - expect(callbacks[1].forward).toBe(forward2) - expect(callbacks[2].time).toBe(1.5) - expect(callbacks[2].forward).toBe(forward3) + expect(animations.get(a)!.transition.x.duration).toBe(2) + expect(animations.get(a)!.transition.x.times).toEqual([0, 0.5]) + expect(animations.get(b)!.transition.y.times).toEqual([0, 0.5, 1]) }) - test("It extracts callbacks with forward and backward", () => { - const forward = () => {} - const backward = () => {} - - const { callbacks } = createAnimationsFromSequence( + test("Callback segments are skipped in animation definitions", () => { + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ forward, backward }, { at: 0.5 }], - ], - undefined, - undefined, - { spring } - ) - - expect(callbacks.length).toBe(1) - expect(callbacks[0].forward).toBe(forward) - expect(callbacks[0].backward).toBe(backward) - }) - - test("It extracts callbacks with label-based timing", () => { - const { callbacks } = createAnimationsFromSequence( - [ - "my-label", - [a, { x: 100 }, { duration: 1 }], - [{ forward: () => {} }, { at: "my-label" }], + [{ forward: () => {} }, { at: 0.5 }], ], undefined, undefined, { spring } ) - expect(callbacks.length).toBe(1) - expect(callbacks[0].time).toBe(0) // Label was set at time 0 - }) - - test("Callbacks don't affect animation timing", () => { - const { animationDefinitions, totalDuration } = - createAnimationsFromSequence( - [ - [a, { x: 100 }, { duration: 1 }], - [{ forward: () => {} }, {}], - [{ forward: () => {} }, {}], - [{ forward: () => {} }, {}], - [b, { y: 200 }, { duration: 1 }], - ], - undefined, - undefined, - { spring } - ) - - // Callbacks should not add to the timeline duration - expect(totalDuration).toBe(2) - - // Animations should be positioned correctly - expect(animationDefinitions.get(a)!.transition.x.times).toEqual([0, 0.5]) - expect(animationDefinitions.get(b)!.transition.y.times).toEqual([ - 0, 0.5, 1, - ]) + // Only the element animation, no callback artifacts + expect(animations.size).toBe(1) + expect(animations.has(a)).toBe(true) }) }) diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 0615f172e0..8c167975f1 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -41,22 +41,16 @@ const defaultSegmentEasing = "easeInOut" const MAX_REPEAT = 20 -export interface CreateAnimationsResult { - animationDefinitions: ResolvedAnimationDefinitions - callbacks: ResolvedSequenceCallback[] - totalDuration: number -} - export function createAnimationsFromSequence( sequence: AnimationSequence, { defaultTransition = {}, ...sequenceTransition }: SequenceOptions = {}, scope?: AnimationScope, - generators?: { [key: string]: GeneratorFactory } -): CreateAnimationsResult { + generators?: { [key: string]: GeneratorFactory }, + outCallbacks?: ResolvedSequenceCallback[] +): ResolvedAnimationDefinitions { const defaultDuration = defaultTransition.duration || 0.3 const animationDefinitions: ResolvedAnimationDefinitions = new Map() const sequences = new Map() - const callbacks: ResolvedSequenceCallback[] = [] const elementCache = {} const timeLabels = new Map() @@ -90,17 +84,24 @@ export function createAnimationsFromSequence( * If this is a callback segment, extract the callback and its timing */ if (isCallbackSegment(segment)) { - const [callback, options] = segment - const callbackTime = - options.at !== undefined - ? calcNextTime(currentTime, options.at, prevTime, timeLabels) - : currentTime - - callbacks.push({ - time: callbackTime, - forward: callback.forward, - backward: callback.backward, - }) + if (outCallbacks) { + const [callback, options] = segment + const callbackTime = + options.at !== undefined + ? calcNextTime( + currentTime, + options.at, + prevTime, + timeLabels + ) + : currentTime + + outCallbacks.push({ + time: callbackTime, + forward: callback.forward, + backward: callback.backward, + }) + } continue } @@ -417,10 +418,11 @@ export function createAnimationsFromSequence( } }) - // Sort callbacks by time for efficient lookup during playback - callbacks.sort((a, b) => a.time - b.time) + if (outCallbacks) { + outCallbacks.sort((a, b) => a.time - b.time) + } - return { animationDefinitions, callbacks, totalDuration } + return animationDefinitions } function getSubjectSequence( From a301d307db94f6f6bdcf9aa8c57b2b531f84190a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:07:09 +0000 Subject: [PATCH 06/16] Fix wrong onPan/onDrag event sequence (issue #2056) onPanStart/onDragStart were deferred via frame.postRender(), causing them to fire after onPan/onDrag which ran synchronously in the update phase. Fix by using frame.update(callback, false, true) for all pan/drag event handlers. The third argument (immediate) schedules callbacks into the currently executing update step, so they fire within the same frame pass in the order they were scheduled: start before move. https://claude.ai/code/session_01NUF3Mj5SSALV5gSW6AVGn6 --- .../src/gestures/__tests__/pan.test.tsx | 34 +++++++++++++++++++ .../drag/VisualElementDragControls.ts | 6 ++-- .../framer-motion/src/gestures/pan/index.ts | 4 +-- 3 files changed, 40 insertions(+), 4 deletions(-) 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 5a2001996b..ec16328f8e 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) { From 3c1d42f3561c89bf0e57ae7368b3c9230bd039c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 11:27:20 +0000 Subject: [PATCH 07/16] feat: Rename callback methods to enter/leave, add comprehensive tests, fix timing assertion - Rename forward/backward to enter/leave across types, create, and sequence - Add 4 integration tests: scrubbing thresholds, complete(), cancel() without scrub, cancel() after scrub - Fix unit test expectation for trailing keyframe offset in callback timing test https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF --- .../animate/__tests__/animate.test.tsx | 133 ++++++++++++++++++ .../src/animation/animate/sequence.ts | 4 +- .../sequence/__tests__/index.test.ts | 10 +- .../src/animation/sequence/create.ts | 8 +- .../src/animation/sequence/types.ts | 12 +- 5 files changed, 150 insertions(+), 17 deletions(-) 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..b11a1a72a5 100644 --- a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx @@ -483,3 +483,136 @@ describe("animate: Objects", () => { warn.mockRestore() }) }) + +describe("Sequence callbacks", () => { + function waitForFrame(): Promise { + return new Promise((resolve) => setTimeout(resolve, 50)) + } + + test("Scrubbing fires enter/leave at correct thresholds", async () => { + const element = document.createElement("div") + let enterCount = 0 + let leaveCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + leave: () => leaveCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + expect(animation.duration).toBe(2) + + animation.pause() + + // Scrub to 0.5 - enter not called (callback is at t=1) + animation.time = 0.5 + await waitForFrame() + expect(enterCount).toBe(0) + expect(leaveCount).toBe(0) + + // Scrub to 1 - enter called + animation.time = 1 + await waitForFrame() + expect(enterCount).toBe(1) + expect(leaveCount).toBe(0) + + // Scrub to 1.5 - enter still called once (no re-fire) + animation.time = 1.5 + await waitForFrame() + expect(enterCount).toBe(1) + expect(leaveCount).toBe(0) + + // Scrub back to 0.5 - leave called once + animation.time = 0.5 + await waitForFrame() + expect(enterCount).toBe(1) + expect(leaveCount).toBe(1) + + // Scrub to 1.5 again - enter called twice total + animation.time = 1.5 + await waitForFrame() + expect(enterCount).toBe(2) + expect(leaveCount).toBe(1) + }) + + test("complete() fires enter once", async () => { + const element = document.createElement("div") + let enterCount = 0 + let leaveCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + leave: () => leaveCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + animation.complete() + await waitForFrame() + + expect(enterCount).toBe(1) + expect(leaveCount).toBe(0) + }) + + test("cancel() without scrubbing fires neither enter nor leave", async () => { + const element = document.createElement("div") + let enterCount = 0 + let leaveCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + leave: () => leaveCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + animation.cancel() + + expect(enterCount).toBe(0) + expect(leaveCount).toBe(0) + }) + + test("cancel() after scrubbing forward fires leave", async () => { + const element = document.createElement("div") + let enterCount = 0 + let leaveCount = 0 + + const animation = animate([ + [element, { opacity: 1 }, { duration: 1 }], + [ + { + enter: () => enterCount++, + leave: () => leaveCount++, + }, + {}, + ], + [element, { opacity: 0 }, { duration: 1 }], + ]) + + animation.pause() + animation.time = 1.5 + await waitForFrame() + + expect(enterCount).toBe(1) + + animation.cancel() + + expect(leaveCount).toBe(1) + }) +}) diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index 2e3a223596..b8b41f7c1d 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -29,9 +29,9 @@ function createCallbackUpdater( const prevTime = prevProgress * totalDuration if (prevTime < callback.time && currentTime >= callback.time) { - callback.forward?.() + callback.enter?.() } else if (prevTime >= callback.time && currentTime < callback.time) { - callback.backward?.() + callback.leave?.() } } 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 4b736d33d5..d3595a61f4 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -847,9 +847,9 @@ describe("Sequence callbacks", () => { const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ forward: () => {} }, {}], - [{ forward: () => {} }, {}], - [{ forward: () => {} }, {}], + [{ enter: () => {} }, {}], + [{ enter: () => {} }, {}], + [{ enter: () => {} }, {}], [b, { y: 200 }, { duration: 1 }], ], undefined, @@ -858,7 +858,7 @@ describe("Sequence callbacks", () => { ) expect(animations.get(a)!.transition.x.duration).toBe(2) - expect(animations.get(a)!.transition.x.times).toEqual([0, 0.5]) + expect(animations.get(a)!.transition.x.times).toEqual([0, 0.5, 1]) expect(animations.get(b)!.transition.y.times).toEqual([0, 0.5, 1]) }) @@ -866,7 +866,7 @@ describe("Sequence callbacks", () => { const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ forward: () => {} }, { at: 0.5 }], + [{ enter: () => {} }, { at: 0.5 }], ], undefined, undefined, diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 8c167975f1..fa6323106e 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -98,8 +98,8 @@ export function createAnimationsFromSequence( outCallbacks.push({ time: callbackTime, - forward: callback.forward, - backward: callback.backward, + enter: callback.enter, + leave: callback.leave, }) } continue @@ -470,9 +470,9 @@ function isCallbackSegment( if (!Array.isArray(segment) || segment.length !== 2) return false const [callback, options] = segment if (typeof callback !== "object" || callback === null) return false - // It's a callback if it has forward or backward and no other animation properties + // It's a callback if it has enter or leave and no other animation properties return ( - ("forward" in callback || "backward" in callback) && + ("enter" in callback || "leave" in callback) && !("duration" in options) && !("ease" in options) ) diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index c245b6d400..d5aa791525 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -60,12 +60,12 @@ export type ObjectSegmentWithTransition = [ /** * Callback to be invoked at a specific point in the sequence. - * - `forward`: Called when time crosses this point moving forward - * - `backward`: Called when time crosses this point moving backward (for scrubbing) + * - `enter`: Called when time crosses this point moving forward + * - `leave`: Called when time crosses this point moving backward (for scrubbing) */ export interface SequenceCallback { - forward?: VoidFunction - backward?: VoidFunction + enter?: VoidFunction + leave?: VoidFunction } export type CallbackSegment = [SequenceCallback, At] @@ -117,6 +117,6 @@ export type ResolvedAnimationDefinitions = Map< */ export interface ResolvedSequenceCallback { time: number - forward?: VoidFunction - backward?: VoidFunction + enter?: VoidFunction + leave?: VoidFunction } From 62eb507cdffe9d3c4e421baee4fa1520c8d677ae Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 12:49:16 +0100 Subject: [PATCH 08/16] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 359915f5ef..3a8c7601dd 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.30.2] 2026-02-03 + +### Fixed + +- Ensure `onPan` never fires before `onPanStart`. + ## [12.30.1] 2026-02-03 ### Fixed From 797cb3df66dfcce7b63e57e569a736ba50db212f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 12:49:30 +0100 Subject: [PATCH 09/16] v12.30.2 --- dev/html/package.json | 6 +++--- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 16 ++++++++-------- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index d7d43cdd73..b0b33764a5 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.30.2", "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.30.2", + "motion": "^12.30.2", "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..fd3cf39114 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.30.2", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.30.1", + "motion": "^12.30.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 e258ab4910..64a494db0c 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.30.2", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.30.1", + "motion": "^12.30.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index aa89f7b533..a0ed10a6d6 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.30.2", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.30.1", + "framer-motion": "^12.30.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 5ac1a87dcd..032d1a35f5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.30.1", + "version": "12.30.2", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index ca60828950..3509979b30 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.30.2", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", diff --git a/packages/motion/package.json b/packages/motion/package.json index ba1ead3ab4..e8d8f09e4b 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.30.1", + "version": "12.30.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.30.1", + "framer-motion": "^12.30.2", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 1830c98867..45b5eb909d 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.30.2, 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.30.2 + motion: ^12.30.2 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.30.2, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.30.1 + framer-motion: ^12.30.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.30.1 + motion: ^12.30.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.30.1 + motion: ^12.30.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.30.1 + framer-motion: ^12.30.2 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 8ad3881f4dea5aa7194ee04f57b06e1549e89926 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 13:05:51 +0100 Subject: [PATCH 10/16] refactor: Rename leave to exit, clean up callback plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename leave → exit across types, create, sequence, and tests - Replace fragile duration lookup with SequenceCallbackData out-parameter - Simplify isCallbackSegment type guard - Use optional chaining for callbackData in create.ts Co-Authored-By: Claude Opus 4.5 --- .../animate/__tests__/animate.test.tsx | 38 ++++++------ .../src/animation/animate/sequence.ts | 26 +++----- .../src/animation/sequence/create.ts | 61 ++++++++----------- .../src/animation/sequence/types.ts | 11 +++- 4 files changed, 63 insertions(+), 73 deletions(-) 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 b11a1a72a5..6bbefd5cd8 100644 --- a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx @@ -489,17 +489,17 @@ describe("Sequence callbacks", () => { return new Promise((resolve) => setTimeout(resolve, 50)) } - test("Scrubbing fires enter/leave at correct thresholds", async () => { + test("Scrubbing fires enter/exit at correct thresholds", async () => { const element = document.createElement("div") let enterCount = 0 - let leaveCount = 0 + let exitCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { enter: () => enterCount++, - leave: () => leaveCount++, + exit: () => exitCount++, }, {}, ], @@ -514,44 +514,44 @@ describe("Sequence callbacks", () => { animation.time = 0.5 await waitForFrame() expect(enterCount).toBe(0) - expect(leaveCount).toBe(0) + expect(exitCount).toBe(0) // Scrub to 1 - enter called animation.time = 1 await waitForFrame() expect(enterCount).toBe(1) - expect(leaveCount).toBe(0) + expect(exitCount).toBe(0) // Scrub to 1.5 - enter still called once (no re-fire) animation.time = 1.5 await waitForFrame() expect(enterCount).toBe(1) - expect(leaveCount).toBe(0) + expect(exitCount).toBe(0) // Scrub back to 0.5 - leave called once animation.time = 0.5 await waitForFrame() expect(enterCount).toBe(1) - expect(leaveCount).toBe(1) + expect(exitCount).toBe(1) // Scrub to 1.5 again - enter called twice total animation.time = 1.5 await waitForFrame() expect(enterCount).toBe(2) - expect(leaveCount).toBe(1) + expect(exitCount).toBe(1) }) test("complete() fires enter once", async () => { const element = document.createElement("div") let enterCount = 0 - let leaveCount = 0 + let exitCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { enter: () => enterCount++, - leave: () => leaveCount++, + exit: () => exitCount++, }, {}, ], @@ -562,20 +562,20 @@ describe("Sequence callbacks", () => { await waitForFrame() expect(enterCount).toBe(1) - expect(leaveCount).toBe(0) + expect(exitCount).toBe(0) }) - test("cancel() without scrubbing fires neither enter nor leave", async () => { + test("cancel() without scrubbing fires neither enter nor exit", async () => { const element = document.createElement("div") let enterCount = 0 - let leaveCount = 0 + let exitCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { enter: () => enterCount++, - leave: () => leaveCount++, + exit: () => exitCount++, }, {}, ], @@ -585,20 +585,20 @@ describe("Sequence callbacks", () => { animation.cancel() expect(enterCount).toBe(0) - expect(leaveCount).toBe(0) + expect(exitCount).toBe(0) }) - test("cancel() after scrubbing forward fires leave", async () => { + test("cancel() after scrubbing forward fires exit", async () => { const element = document.createElement("div") let enterCount = 0 - let leaveCount = 0 + let exitCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { enter: () => enterCount++, - leave: () => leaveCount++, + exit: () => exitCount++, }, {}, ], @@ -613,6 +613,6 @@ describe("Sequence callbacks", () => { animation.cancel() - expect(leaveCount).toBe(1) + expect(exitCount).toBe(1) }) }) diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index b8b41f7c1d..4fe9a3808d 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -8,6 +8,7 @@ import { createAnimationsFromSequence } from "../sequence/create" import { AnimationSequence, ResolvedSequenceCallback, + SequenceCallbackData, SequenceOptions, } from "../sequence/types" import { animateSubject } from "./subject" @@ -31,7 +32,7 @@ function createCallbackUpdater( if (prevTime < callback.time && currentTime >= callback.time) { callback.enter?.() } else if (prevTime >= callback.time && currentTime < callback.time) { - callback.leave?.() + callback.exit?.() } } @@ -45,35 +46,28 @@ export function animateSequence( scope?: AnimationScope ) { const animations: AnimationPlaybackControlsWithThen[] = [] - const callbacks: ResolvedSequenceCallback[] = [] + const callbackData: SequenceCallbackData = { callbacks: [], totalDuration: 0 } const animationDefinitions = createAnimationsFromSequence( sequence, options, scope, { spring }, - callbacks + callbackData ) animationDefinitions.forEach(({ keyframes, transition }, subject) => { animations.push(...animateSubject(subject, keyframes, transition)) }) - if (callbacks.length > 0) { - /** - * Read totalDuration from the first animation's transition, - * since all animations in a sequence share the same duration. - */ - const firstTransition = animationDefinitions.values().next().value - ?.transition - const totalDuration = firstTransition - ? (Object.values(firstTransition)[0] as any)?.duration ?? 0 - : 0 - + if (callbackData.callbacks.length) { const callbackAnimation = animateSingleValue(0, 1, { - duration: totalDuration, + duration: callbackData.totalDuration, ease: "linear", - onUpdate: createCallbackUpdater(callbacks, totalDuration), + onUpdate: createCallbackUpdater( + callbackData.callbacks, + callbackData.totalDuration + ), }) animations.push(callbackAnimation) } diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index fa6323106e..b414b319d6 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -24,9 +24,9 @@ import { resolveSubjects } from "../animate/resolve-subjects" import { AnimationSequence, At, + CallbackSegment, ResolvedAnimationDefinitions, - ResolvedSequenceCallback, - SequenceCallback, + SequenceCallbackData, SequenceMap, SequenceOptions, ValueSequence, @@ -46,7 +46,7 @@ export function createAnimationsFromSequence( { defaultTransition = {}, ...sequenceTransition }: SequenceOptions = {}, scope?: AnimationScope, generators?: { [key: string]: GeneratorFactory }, - outCallbacks?: ResolvedSequenceCallback[] + callbackData?: SequenceCallbackData ): ResolvedAnimationDefinitions { const defaultDuration = defaultTransition.duration || 0.3 const animationDefinitions: ResolvedAnimationDefinitions = new Map() @@ -84,24 +84,22 @@ export function createAnimationsFromSequence( * If this is a callback segment, extract the callback and its timing */ if (isCallbackSegment(segment)) { - if (outCallbacks) { - const [callback, options] = segment - const callbackTime = - options.at !== undefined - ? calcNextTime( - currentTime, - options.at, - prevTime, - timeLabels - ) - : currentTime - - outCallbacks.push({ - time: callbackTime, - enter: callback.enter, - leave: callback.leave, - }) - } + const [callback, options] = segment + const callbackTime = + options.at !== undefined + ? calcNextTime( + currentTime, + options.at, + prevTime, + timeLabels + ) + : currentTime + + callbackData?.callbacks.push({ + time: callbackTime, + enter: callback.enter, + exit: callback.exit, + }) continue } @@ -418,8 +416,9 @@ export function createAnimationsFromSequence( } }) - if (outCallbacks) { - outCallbacks.sort((a, b) => a.time - b.time) + if (callbackData) { + callbackData.callbacks.sort((a, b) => a.time - b.time) + callbackData.totalDuration = totalDuration } return animationDefinitions @@ -462,18 +461,10 @@ const isNumberKeyframesArray = ( ): keyframes is number[] => keyframes.every(isNumber) /** - * Check if a segment is a callback segment: [{ forward?, backward? }, { at? }] + * Check if a segment is a callback segment: [{ enter?, exit? }, { at? }] */ function isCallbackSegment( - segment: unknown -): segment is [SequenceCallback, At] { - if (!Array.isArray(segment) || segment.length !== 2) return false - const [callback, options] = segment - if (typeof callback !== "object" || callback === null) return false - // It's a callback if it has enter or leave and no other animation properties - return ( - ("enter" in callback || "leave" in callback) && - !("duration" in options) && - !("ease" in options) - ) + segment: any[] +): segment is CallbackSegment { + return "enter" in segment[0] || "exit" in segment[0] } diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index d5aa791525..da8fdc1925 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -61,11 +61,11 @@ export type ObjectSegmentWithTransition = [ /** * Callback to be invoked at a specific point in the sequence. * - `enter`: Called when time crosses this point moving forward - * - `leave`: Called when time crosses this point moving backward (for scrubbing) + * - `exit`: Called when time crosses this point moving backward (for scrubbing) */ export interface SequenceCallback { enter?: VoidFunction - leave?: VoidFunction + exit?: VoidFunction } export type CallbackSegment = [SequenceCallback, At] @@ -118,5 +118,10 @@ export type ResolvedAnimationDefinitions = Map< export interface ResolvedSequenceCallback { time: number enter?: VoidFunction - leave?: VoidFunction + exit?: VoidFunction +} + +export interface SequenceCallbackData { + callbacks: ResolvedSequenceCallback[] + totalDuration: number } From 7b59416681bb47c519b7d2c91448a459c005e5a6 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 13:09:55 +0100 Subject: [PATCH 11/16] fix: Guard against null elements in isCallbackSegment Co-Authored-By: Claude Opus 4.5 --- packages/framer-motion/src/animation/sequence/create.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index b414b319d6..b14d90e57d 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -466,5 +466,5 @@ const isNumberKeyframesArray = ( function isCallbackSegment( segment: any[] ): segment is CallbackSegment { - return "enter" in segment[0] || "exit" in segment[0] + return segment[0] && ("enter" in segment[0] || "exit" in segment[0]) } From 2ebf307637702dee29f8147a3c964ffc61f1f9c9 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 13:16:34 +0100 Subject: [PATCH 12/16] fix: Move sequence callback tests before polluting test Reorder describe blocks so callback tests run before the buggy "non-animatable color" test that corrupts frame loop state. Co-Authored-By: Claude Opus 4.5 --- .../animate/__tests__/animate.test.tsx | 224 +++++++++--------- 1 file changed, 112 insertions(+), 112 deletions(-) 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 6bbefd5cd8..a8039bef99 100644 --- a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx @@ -373,117 +373,6 @@ describe("animate", () => { }) }) -describe("animate: Objects", () => { - test("Types: Object to object", () => { - animate({ x: 100 }, { x: 200 }) - animate({ x: 100, y: 0 }, { x: 200 }) - }) - - test("Types: Object to object with transition", () => { - animate({ x: 100 }, { x: 200 }, { duration: 0.01 }) - }) - - test("Types: Object to object with value-specific transitions", () => { - animate({ x: 100 }, { x: 200 }, { x: { duration: 0.01 } }) - }) - - test("Types: Object in sequence", () => { - animate([[{ x: 100 }, { x: 200 }]]) - }) - - test("Types: Object in sequence with transition", () => { - animate([[{ x: 100 }, { x: 200 }, { duration: 1 }]]) - }) - - test("Types: Object in sequence with value-specific transition", () => { - animate([[{ x: 100 }, { x: 200 }, { x: { duration: 1 } }]]) - }) - - test("Types: Object to object with onUpdate", () => { - const output = { x: 0 } - animate( - { x: 100 }, - { x: 200 }, - { - onUpdate: (latest) => { - output.x = latest.x - }, - } - ) - }) - - test("Types: Three.js Object3D", () => { - const object = new THREE.Object3D() - animate(object.rotation, { x: 10 }, { duration: 0.01 }) - }) - - test("Types: Three.js Object3D keyframes", () => { - const object = new THREE.Object3D() - animate(object.rotation, { x: [null, 10] }, { duration: 0.01 }) - }) - - test("Types: Three.js Object3D in sequence", () => { - const object = new THREE.Object3D() - animate([[object.rotation, { x: 10 }]]) - }) - - test("Types: Three.js Object3D in sequence with transition", () => { - const object = new THREE.Object3D() - animate([[object.rotation, { x: 10 }, { duration: 0.01 }]]) - }) - - test("Object animates", async () => { - const obj = { x: 100 } - await animate(obj, { x: 200 }, { duration: 0.01 }) - expect(obj.x).toBe(200) - }) - - test("Three.js Object3D animates", async () => { - const obj = new THREE.Object3D() - await animate(obj.rotation, { x: 10 }, { duration: 0.01 }) - expect(obj.rotation.x).toBe(10) - }) - - test("Object animates in sequence", async () => { - const obj = { x: 100 } - await animate([[obj, { x: 200 }, { duration: 0.01 }]]) - expect(obj.x).toBe(200) - }) - - test("Three.js Object3D animates in sequence", async () => { - const obj = new THREE.Object3D() - await animate([[obj.rotation, { x: 10 }, { duration: 0.01 }]]) - expect(obj.rotation.x).toBe(10) - }) - - test("sets motion value to target when animating non-animatable color with type: false", async () => { - const colorValue = motionValue("#000") - - const animation = animate(colorValue, "transparent", { type: false }) - await animation.finished.then((resolve) => { - frame.postRender(() => { - expect(colorValue.get()).toBe("transparent") - resolve() - }) - }) - }) - - test("does not fire console warning when animating non-animatable color with type: false", async () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => {}) - - const colorValue = motionValue("#000") - - // This would normally trigger a warning because "transparent" is not - // recognized as an animatable color, but with type: false it should not warn - const animation = animate(colorValue, "transparent", { type: false }) - await animation.finished - - expect(warn).not.toHaveBeenCalled() - - warn.mockRestore() - }) -}) - describe("Sequence callbacks", () => { function waitForFrame(): Promise { return new Promise((resolve) => setTimeout(resolve, 50)) @@ -528,7 +417,7 @@ describe("Sequence callbacks", () => { expect(enterCount).toBe(1) expect(exitCount).toBe(0) - // Scrub back to 0.5 - leave called once + // Scrub back to 0.5 - exit called once animation.time = 0.5 await waitForFrame() expect(enterCount).toBe(1) @@ -616,3 +505,114 @@ describe("Sequence callbacks", () => { expect(exitCount).toBe(1) }) }) + +describe("animate: Objects", () => { + test("Types: Object to object", () => { + animate({ x: 100 }, { x: 200 }) + animate({ x: 100, y: 0 }, { x: 200 }) + }) + + test("Types: Object to object with transition", () => { + animate({ x: 100 }, { x: 200 }, { duration: 0.01 }) + }) + + test("Types: Object to object with value-specific transitions", () => { + animate({ x: 100 }, { x: 200 }, { x: { duration: 0.01 } }) + }) + + test("Types: Object in sequence", () => { + animate([[{ x: 100 }, { x: 200 }]]) + }) + + test("Types: Object in sequence with transition", () => { + animate([[{ x: 100 }, { x: 200 }, { duration: 1 }]]) + }) + + test("Types: Object in sequence with value-specific transition", () => { + animate([[{ x: 100 }, { x: 200 }, { x: { duration: 1 } }]]) + }) + + test("Types: Object to object with onUpdate", () => { + const output = { x: 0 } + animate( + { x: 100 }, + { x: 200 }, + { + onUpdate: (latest) => { + output.x = latest.x + }, + } + ) + }) + + test("Types: Three.js Object3D", () => { + const object = new THREE.Object3D() + animate(object.rotation, { x: 10 }, { duration: 0.01 }) + }) + + test("Types: Three.js Object3D keyframes", () => { + const object = new THREE.Object3D() + animate(object.rotation, { x: [null, 10] }, { duration: 0.01 }) + }) + + test("Types: Three.js Object3D in sequence", () => { + const object = new THREE.Object3D() + animate([[object.rotation, { x: 10 }]]) + }) + + test("Types: Three.js Object3D in sequence with transition", () => { + const object = new THREE.Object3D() + animate([[object.rotation, { x: 10 }, { duration: 0.01 }]]) + }) + + test("Object animates", async () => { + const obj = { x: 100 } + await animate(obj, { x: 200 }, { duration: 0.01 }) + expect(obj.x).toBe(200) + }) + + test("Three.js Object3D animates", async () => { + const obj = new THREE.Object3D() + await animate(obj.rotation, { x: 10 }, { duration: 0.01 }) + expect(obj.rotation.x).toBe(10) + }) + + test("Object animates in sequence", async () => { + const obj = { x: 100 } + await animate([[obj, { x: 200 }, { duration: 0.01 }]]) + expect(obj.x).toBe(200) + }) + + test("Three.js Object3D animates in sequence", async () => { + const obj = new THREE.Object3D() + await animate([[obj.rotation, { x: 10 }, { duration: 0.01 }]]) + expect(obj.rotation.x).toBe(10) + }) + + test("sets motion value to target when animating non-animatable color with type: false", async () => { + const colorValue = motionValue("#000") + + const animation = animate(colorValue, "transparent", { type: false }) + await animation.finished.then((resolve) => { + frame.postRender(() => { + expect(colorValue.get()).toBe("transparent") + resolve() + }) + }) + }) + + test("does not fire console warning when animating non-animatable color with type: false", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}) + + const colorValue = motionValue("#000") + + // This would normally trigger a warning because "transparent" is not + // recognized as an animatable color, but with type: false it should not warn + const animation = animate(colorValue, "transparent", { type: false }) + await animation.finished + + expect(warn).not.toHaveBeenCalled() + + warn.mockRestore() + }) +}) From 8edb3ae571368eecb12e2172268afb7556f8b982 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 13:27:42 +0100 Subject: [PATCH 13/16] Updating changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8c7601dd..6a7a1a7056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. -## [12.30.2] 2026-02-03 +## [12.31.0] 2026-02-03 + +### Added + +- `animate`: Support for bi-directional callbacks within animation sequences. ### Fixed From a1818ba5d12c7960d5ee3ff2240c0ea0bdebf677 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 13:27:56 +0100 Subject: [PATCH 14/16] v12.31.0 --- dev/html/package.json | 6 +++--- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 16 ++++++++-------- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index b0b33764a5..86233b9745 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.30.2", + "version": "12.31.0", "type": "module", "scripts": { "dev": "vite", @@ -10,8 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.30.2", - "motion": "^12.30.2", + "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 fd3cf39114..c5290d60a2 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.30.2", + "version": "12.31.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.30.2", + "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 64a494db0c..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.2", + "version": "12.31.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.30.2", + "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 a0ed10a6d6..36ac4e41cb 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.30.2", + "version": "12.31.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.30.2", + "framer-motion": "^12.31.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 032d1a35f5..01d43bce76 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.30.2", + "version": "12.31.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 3509979b30..bfb25eac8e 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.30.2", + "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/motion/package.json b/packages/motion/package.json index e8d8f09e4b..e825a2018a 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.30.2", + "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.2", + "framer-motion": "^12.31.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 45b5eb909d..6069017874 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,7 +7420,7 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.30.2, 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.2 - motion: ^12.30.2 + 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.2, 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.2 + 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.2 + 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.2 + 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.2 + framer-motion: ^12.31.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From 82584e285bd775be4919718b3715af6a3d28b026 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 13:40:58 +0100 Subject: [PATCH 15/16] refactor: Rename sequence callbacks from enter/exit to do/undo Co-Authored-By: Claude Opus 4.5 --- .../animate/__tests__/animate.test.tsx | 82 +++++++++---------- .../src/animation/animate/sequence.ts | 4 +- .../sequence/__tests__/index.test.ts | 8 +- .../src/animation/sequence/create.ts | 8 +- .../src/animation/sequence/types.ts | 12 +-- 5 files changed, 57 insertions(+), 57 deletions(-) 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 a8039bef99..d0cd9ee353 100644 --- a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx @@ -378,17 +378,17 @@ describe("Sequence callbacks", () => { return new Promise((resolve) => setTimeout(resolve, 50)) } - test("Scrubbing fires enter/exit at correct thresholds", async () => { + test("Scrubbing fires do/undo at correct thresholds", async () => { const element = document.createElement("div") - let enterCount = 0 - let exitCount = 0 + let doCount = 0 + let undoCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { - enter: () => enterCount++, - exit: () => exitCount++, + do: () => doCount++, + undo: () => undoCount++, }, {}, ], @@ -399,48 +399,48 @@ describe("Sequence callbacks", () => { animation.pause() - // Scrub to 0.5 - enter not called (callback is at t=1) + // Scrub to 0.5 - do not called (callback is at t=1) animation.time = 0.5 await waitForFrame() - expect(enterCount).toBe(0) - expect(exitCount).toBe(0) + expect(doCount).toBe(0) + expect(undoCount).toBe(0) - // Scrub to 1 - enter called + // Scrub to 1 - do called animation.time = 1 await waitForFrame() - expect(enterCount).toBe(1) - expect(exitCount).toBe(0) + expect(doCount).toBe(1) + expect(undoCount).toBe(0) - // Scrub to 1.5 - enter still called once (no re-fire) + // Scrub to 1.5 - do still called once (no re-fire) animation.time = 1.5 await waitForFrame() - expect(enterCount).toBe(1) - expect(exitCount).toBe(0) + expect(doCount).toBe(1) + expect(undoCount).toBe(0) - // Scrub back to 0.5 - exit called once + // Scrub back to 0.5 - undo called once animation.time = 0.5 await waitForFrame() - expect(enterCount).toBe(1) - expect(exitCount).toBe(1) + expect(doCount).toBe(1) + expect(undoCount).toBe(1) - // Scrub to 1.5 again - enter called twice total + // Scrub to 1.5 again - do called twice total animation.time = 1.5 await waitForFrame() - expect(enterCount).toBe(2) - expect(exitCount).toBe(1) + expect(doCount).toBe(2) + expect(undoCount).toBe(1) }) - test("complete() fires enter once", async () => { + test("complete() fires do once", async () => { const element = document.createElement("div") - let enterCount = 0 - let exitCount = 0 + let doCount = 0 + let undoCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { - enter: () => enterCount++, - exit: () => exitCount++, + do: () => doCount++, + undo: () => undoCount++, }, {}, ], @@ -450,21 +450,21 @@ describe("Sequence callbacks", () => { animation.complete() await waitForFrame() - expect(enterCount).toBe(1) - expect(exitCount).toBe(0) + expect(doCount).toBe(1) + expect(undoCount).toBe(0) }) - test("cancel() without scrubbing fires neither enter nor exit", async () => { + test("cancel() without scrubbing fires neither do nor undo", async () => { const element = document.createElement("div") - let enterCount = 0 - let exitCount = 0 + let doCount = 0 + let undoCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { - enter: () => enterCount++, - exit: () => exitCount++, + do: () => doCount++, + undo: () => undoCount++, }, {}, ], @@ -473,21 +473,21 @@ describe("Sequence callbacks", () => { animation.cancel() - expect(enterCount).toBe(0) - expect(exitCount).toBe(0) + expect(doCount).toBe(0) + expect(undoCount).toBe(0) }) - test("cancel() after scrubbing forward fires exit", async () => { + test("cancel() after scrubbing forward fires undo", async () => { const element = document.createElement("div") - let enterCount = 0 - let exitCount = 0 + let doCount = 0 + let undoCount = 0 const animation = animate([ [element, { opacity: 1 }, { duration: 1 }], [ { - enter: () => enterCount++, - exit: () => exitCount++, + do: () => doCount++, + undo: () => undoCount++, }, {}, ], @@ -498,11 +498,11 @@ describe("Sequence callbacks", () => { animation.time = 1.5 await waitForFrame() - expect(enterCount).toBe(1) + expect(doCount).toBe(1) animation.cancel() - expect(exitCount).toBe(1) + expect(undoCount).toBe(1) }) }) diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index 4fe9a3808d..1774d86a14 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -30,9 +30,9 @@ function createCallbackUpdater( const prevTime = prevProgress * totalDuration if (prevTime < callback.time && currentTime >= callback.time) { - callback.enter?.() + callback.do?.() } else if (prevTime >= callback.time && currentTime < callback.time) { - callback.exit?.() + callback.undo?.() } } 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 d3595a61f4..bbbe4afb6c 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -847,9 +847,9 @@ describe("Sequence callbacks", () => { const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ enter: () => {} }, {}], - [{ enter: () => {} }, {}], - [{ enter: () => {} }, {}], + [{ do: () => {} }, {}], + [{ do: () => {} }, {}], + [{ do: () => {} }, {}], [b, { y: 200 }, { duration: 1 }], ], undefined, @@ -866,7 +866,7 @@ describe("Sequence callbacks", () => { const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ enter: () => {} }, { at: 0.5 }], + [{ do: () => {} }, { at: 0.5 }], ], undefined, undefined, diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index b14d90e57d..9d110ae5a3 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -97,8 +97,8 @@ export function createAnimationsFromSequence( callbackData?.callbacks.push({ time: callbackTime, - enter: callback.enter, - exit: callback.exit, + do: callback.do, + undo: callback.undo, }) continue } @@ -461,10 +461,10 @@ const isNumberKeyframesArray = ( ): keyframes is number[] => keyframes.every(isNumber) /** - * Check if a segment is a callback segment: [{ enter?, exit? }, { at? }] + * Check if a segment is a callback segment: [{ do?, undo? }, { at? }] */ function isCallbackSegment( segment: any[] ): segment is CallbackSegment { - return segment[0] && ("enter" in segment[0] || "exit" in segment[0]) + return segment[0] && ("do" in segment[0] || "undo" in segment[0]) } diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index da8fdc1925..0bc377538b 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -60,12 +60,12 @@ export type ObjectSegmentWithTransition = [ /** * Callback to be invoked at a specific point in the sequence. - * - `enter`: Called when time crosses this point moving forward - * - `exit`: Called when time crosses this point moving backward (for scrubbing) + * - `do`: Called when time crosses this point moving forward + * - `undo`: Called when time crosses this point moving backward (for scrubbing) */ export interface SequenceCallback { - enter?: VoidFunction - exit?: VoidFunction + do?: VoidFunction + undo?: VoidFunction } export type CallbackSegment = [SequenceCallback, At] @@ -117,8 +117,8 @@ export type ResolvedAnimationDefinitions = Map< */ export interface ResolvedSequenceCallback { time: number - enter?: VoidFunction - exit?: VoidFunction + do?: VoidFunction + undo?: VoidFunction } export interface SequenceCallbackData { From 0d7a2b7db6a8b07ebf4945d7bd5bf0576dc1d6c8 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Tue, 3 Feb 2026 14:10:33 +0100 Subject: [PATCH 16/16] Adding support for callbacks in animation sequences --- .../animate/__tests__/animate.test.tsx | 167 ++++++++---------- .../src/animation/animate/sequence.ts | 75 +++----- .../sequence/__tests__/index.test.ts | 21 ++- .../src/animation/sequence/create.ts | 42 +---- .../src/animation/sequence/types.ts | 34 ++-- 5 files changed, 126 insertions(+), 213 deletions(-) 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 d0cd9ee353..a6779630ea 100644 --- a/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx +++ b/packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx @@ -378,130 +378,117 @@ describe("Sequence callbacks", () => { return new Promise((resolve) => setTimeout(resolve, 50)) } - test("Scrubbing fires do/undo at correct thresholds", async () => { + test("Progress callback receives interpolated values", async () => { const element = document.createElement("div") - let doCount = 0 - let undoCount = 0 + const values: number[] = [] - const animation = animate([ - [element, { opacity: 1 }, { duration: 1 }], + const animation = animate( [ - { - do: () => doCount++, - undo: () => undoCount++, - }, - {}, + [element, { opacity: 1 }, { duration: 0.5 }], + [(p: number) => values.push(p), { duration: 0.5 }], ], - [element, { opacity: 0 }, { duration: 1 }], - ]) - - expect(animation.duration).toBe(2) - - animation.pause() - - // Scrub to 0.5 - do not called (callback is at t=1) - animation.time = 0.5 - await waitForFrame() - expect(doCount).toBe(0) - expect(undoCount).toBe(0) - - // Scrub to 1 - do called - animation.time = 1 - await waitForFrame() - expect(doCount).toBe(1) - expect(undoCount).toBe(0) - - // Scrub to 1.5 - do still called once (no re-fire) - animation.time = 1.5 - await waitForFrame() - expect(doCount).toBe(1) - expect(undoCount).toBe(0) - - // Scrub back to 0.5 - undo called once - animation.time = 0.5 - await waitForFrame() - expect(doCount).toBe(1) - expect(undoCount).toBe(1) + { + defaultTransition: { + ease: "linear", + }, + } + ) - // Scrub to 1.5 again - do called twice total - animation.time = 1.5 - await waitForFrame() - expect(doCount).toBe(2) - expect(undoCount).toBe(1) + 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("complete() fires do once", async () => { + test("Progress callback with custom keyframes", async () => { const element = document.createElement("div") - let doCount = 0 - let undoCount = 0 + const values: number[] = [] - const animation = animate([ - [element, { opacity: 1 }, { duration: 1 }], + const animation = animate( [ - { - do: () => doCount++, - undo: () => undoCount++, - }, - {}, + [element, { opacity: 1 }, { duration: 0.5 }], + [(v: number) => values.push(v), [0, 100], { duration: 0.5 }], ], - [element, { opacity: 0 }, { duration: 1 }], - ]) - - animation.complete() - await waitForFrame() + { + defaultTransition: { + ease: "linear", + }, + } + ) - expect(doCount).toBe(1) - expect(undoCount).toBe(0) + 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("cancel() without scrubbing fires neither do nor undo", async () => { + 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 }], [ - { - do: () => doCount++, - undo: () => undoCount++, - }, - {}, + toggle( + () => doCount++, + () => undoCount++ + ), + { duration: 0 }, ], [element, { opacity: 0 }, { duration: 1 }], ]) - animation.cancel() + 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) - }) - - test("cancel() after scrubbing forward fires undo", async () => { - const element = document.createElement("div") - let doCount = 0 - let undoCount = 0 - - const animation = animate([ - [element, { opacity: 1 }, { duration: 1 }], - [ - { - do: () => doCount++, - undo: () => undoCount++, - }, - {}, - ], - [element, { opacity: 0 }, { duration: 1 }], - ]) - animation.pause() + // Scrub past threshold - do fires animation.time = 1.5 await waitForFrame() - expect(doCount).toBe(1) + expect(undoCount).toBe(0) - animation.cancel() + // 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) }) }) diff --git a/packages/framer-motion/src/animation/animate/sequence.ts b/packages/framer-motion/src/animation/animate/sequence.ts index 1774d86a14..76b2dd1017 100644 --- a/packages/framer-motion/src/animation/animate/sequence.ts +++ b/packages/framer-motion/src/animation/animate/sequence.ts @@ -1,76 +1,51 @@ import { - animateSingleValue, AnimationPlaybackControlsWithThen, AnimationScope, + motionValue, spring, } from "motion-dom" import { createAnimationsFromSequence } from "../sequence/create" -import { - AnimationSequence, - ResolvedSequenceCallback, - SequenceCallbackData, - SequenceOptions, -} from "../sequence/types" +import { AnimationSequence, SequenceOptions } from "../sequence/types" import { animateSubject } from "./subject" -/** - * Creates an onUpdate callback that fires sequence callbacks when time crosses their thresholds. - * Tracks previous progress to detect direction (forward/backward). - */ -function createCallbackUpdater( - callbacks: ResolvedSequenceCallback[], - totalDuration: number -) { - let prevProgress = 0 - - return (progress: number) => { - const currentTime = progress * totalDuration - - for (const callback of callbacks) { - const prevTime = prevProgress * totalDuration - - if (prevTime < callback.time && currentTime >= callback.time) { - callback.do?.() - } else if (prevTime >= callback.time && currentTime < callback.time) { - callback.undo?.() - } - } - - prevProgress = progress - } -} - export function animateSequence( sequence: AnimationSequence, options?: SequenceOptions, scope?: AnimationScope ) { const animations: AnimationPlaybackControlsWithThen[] = [] - const callbackData: SequenceCallbackData = { callbacks: [], totalDuration: 0 } + + /** + * 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 }, - callbackData + { spring } ) animationDefinitions.forEach(({ keyframes, transition }, subject) => { animations.push(...animateSubject(subject, keyframes, transition)) }) - if (callbackData.callbacks.length) { - const callbackAnimation = animateSingleValue(0, 1, { - duration: callbackData.totalDuration, - ease: "linear", - onUpdate: createCallbackUpdater( - callbackData.callbacks, - callbackData.totalDuration - ), - }) - animations.push(callbackAnimation) - } - return animations } 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 bbbe4afb6c..7a2aea21e3 100644 --- a/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts +++ b/packages/framer-motion/src/animation/sequence/__tests__/index.test.ts @@ -843,13 +843,17 @@ describe("Sequence callbacks", () => { const a = document.createElement("div") const b = document.createElement("div") - test("Callbacks don't affect animation timing", () => { + 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 }], - [{ do: () => {} }, {}], - [{ do: () => {} }, {}], - [{ do: () => {} }, {}], + [mv1, [0, 1], { duration: 0 }], + [mv2, [0, 1], { duration: 0 }], + [mv3, [0, 1], { duration: 0 }], [b, { y: 200 }, { duration: 1 }], ], undefined, @@ -862,19 +866,20 @@ describe("Sequence callbacks", () => { expect(animations.get(b)!.transition.y.times).toEqual([0, 0.5, 1]) }) - test("Callback segments are skipped in animation definitions", () => { + test("Function segments appear as MotionValue entries in animation definitions", () => { + const mv = motionValue(0) + const animations = createAnimationsFromSequence( [ [a, { x: 100 }, { duration: 1 }], - [{ do: () => {} }, { at: 0.5 }], + [mv, [0, 1], { duration: 0.5 }], ], undefined, undefined, { spring } ) - // Only the element animation, no callback artifacts - expect(animations.size).toBe(1) expect(animations.has(a)).toBe(true) + expect(animations.has(mv)).toBe(true) }) }) diff --git a/packages/framer-motion/src/animation/sequence/create.ts b/packages/framer-motion/src/animation/sequence/create.ts index 9d110ae5a3..07ead3c85b 100644 --- a/packages/framer-motion/src/animation/sequence/create.ts +++ b/packages/framer-motion/src/animation/sequence/create.ts @@ -24,9 +24,7 @@ import { resolveSubjects } from "../animate/resolve-subjects" import { AnimationSequence, At, - CallbackSegment, ResolvedAnimationDefinitions, - SequenceCallbackData, SequenceMap, SequenceOptions, ValueSequence, @@ -45,8 +43,7 @@ export function createAnimationsFromSequence( sequence: AnimationSequence, { defaultTransition = {}, ...sequenceTransition }: SequenceOptions = {}, scope?: AnimationScope, - generators?: { [key: string]: GeneratorFactory }, - callbackData?: SequenceCallbackData + generators?: { [key: string]: GeneratorFactory } ): ResolvedAnimationDefinitions { const defaultDuration = defaultTransition.duration || 0.3 const animationDefinitions: ResolvedAnimationDefinitions = new Map() @@ -80,29 +77,6 @@ export function createAnimationsFromSequence( continue } - /** - * If this is a callback segment, extract the callback and its timing - */ - if (isCallbackSegment(segment)) { - const [callback, options] = segment - const callbackTime = - options.at !== undefined - ? calcNextTime( - currentTime, - options.at, - prevTime, - timeLabels - ) - : currentTime - - callbackData?.callbacks.push({ - time: callbackTime, - do: callback.do, - undo: callback.undo, - }) - continue - } - let [subject, keyframes, transition = {}] = segment /** @@ -416,11 +390,6 @@ export function createAnimationsFromSequence( } }) - if (callbackData) { - callbackData.callbacks.sort((a, b) => a.time - b.time) - callbackData.totalDuration = totalDuration - } - return animationDefinitions } @@ -459,12 +428,3 @@ const isNumber = (keyframe: unknown) => typeof keyframe === "number" const isNumberKeyframesArray = ( keyframes: UnresolvedValueKeyframe[] ): keyframes is number[] => keyframes.every(isNumber) - -/** - * Check if a segment is a callback segment: [{ do?, undo? }, { at? }] - */ -function isCallbackSegment( - segment: any[] -): segment is CallbackSegment { - return segment[0] && ("do" in segment[0] || "undo" in segment[0]) -} diff --git a/packages/framer-motion/src/animation/sequence/types.ts b/packages/framer-motion/src/animation/sequence/types.ts index 0bc377538b..9cdbb7cc42 100644 --- a/packages/framer-motion/src/animation/sequence/types.ts +++ b/packages/framer-motion/src/animation/sequence/types.ts @@ -58,17 +58,16 @@ export type ObjectSegmentWithTransition = [ DynamicAnimationOptions & At ] -/** - * Callback to be invoked at a specific point in the sequence. - * - `do`: Called when time crosses this point moving forward - * - `undo`: Called when time crosses this point moving backward (for scrubbing) - */ -export interface SequenceCallback { - do?: VoidFunction - undo?: VoidFunction -} +export type SequenceProgressCallback = (value: any) => void -export type CallbackSegment = [SequenceCallback, At] +export type FunctionSegment = + | [SequenceProgressCallback] + | [SequenceProgressCallback, DynamicAnimationOptions & At] + | [ + SequenceProgressCallback, + UnresolvedValueKeyframe | UnresolvedValueKeyframe[], + DynamicAnimationOptions & At + ] export type Segment = | ObjectSegment @@ -79,7 +78,7 @@ export type Segment = | MotionValueSegmentWithTransition | DOMSegment | DOMSegmentWithTransition - | CallbackSegment + | FunctionSegment export type AnimationSequence = Segment[] @@ -112,16 +111,3 @@ export type ResolvedAnimationDefinitions = Map< ResolvedAnimationDefinition > -/** - * A callback positioned at an absolute time in the sequence - */ -export interface ResolvedSequenceCallback { - time: number - do?: VoidFunction - undo?: VoidFunction -} - -export interface SequenceCallbackData { - callbacks: ResolvedSequenceCallback[] - totalDuration: number -}