diff --git a/dev/react/src/examples/Animation-transition-out.tsx b/dev/react/src/examples/Animation-transition-out.tsx new file mode 100644 index 0000000000..22d1b33f89 --- /dev/null +++ b/dev/react/src/examples/Animation-transition-out.tsx @@ -0,0 +1,29 @@ +import { motion } from "framer-motion" +import { useEffect, useState } from "react" + +/** + * An example of the tween transition type + */ + +const style = { + width: 100, + height: 100, + background: "white", +} +export const App = () => { + const [state, setState] = useState(false) + useEffect(() => { + setTimeout(() => { + setState(true) + }, 300) + }, [state]) + + return ( + + ) +} diff --git a/packages/framer-motion/src/animation/interfaces/visual-element-target.ts b/packages/framer-motion/src/animation/interfaces/visual-element-target.ts index 696afff01d..7ef4fcb0cf 100644 --- a/packages/framer-motion/src/animation/interfaces/visual-element-target.ts +++ b/packages/framer-motion/src/animation/interfaces/visual-element-target.ts @@ -5,7 +5,7 @@ import type { TargetAndTransition } from "../../types" import type { VisualElementAnimationOptions } from "./types" import { animateMotionValue } from "./motion-value" import { setTarget } from "../../render/utils/setters" -import { AnimationPlaybackControls } from "../types" +import { AnimationPlaybackControls, Transition } from "../types" import { getValueTransition } from "../utils/get-value-transition" import { frame } from "../../frameloop" import { getOptimisedAppearId } from "../optimized-appear/get-appear-id" @@ -63,11 +63,27 @@ export function animateTarget( continue } - const valueTransition = { + let valueTransition = { delay, ...getValueTransition(transition || {}, key), } + let outTransition: Transition | undefined + + if (type && value.nextTransition) { + outTransition = value.nextTransition + } + + value.nextTransition = undefined + + if (valueTransition.out) { + value.nextTransition = valueTransition + } + + if (outTransition) { + valueTransition = outTransition + } + /** * If this is the first time a value is being animated, check * to see if we're handling off from an existing animation. diff --git a/packages/framer-motion/src/animation/types.ts b/packages/framer-motion/src/animation/types.ts index b28ff7ba7e..cecbe7f32b 100644 --- a/packages/framer-motion/src/animation/types.ts +++ b/packages/framer-motion/src/animation/types.ts @@ -44,6 +44,33 @@ export interface Transition duration?: number autoplay?: boolean startTime?: number + + /** + * If set to `true`, when the animated value leaves its current + * state, this transition will be used. + * + * For example: + * + * ```jsx + * + * ``` + * + * Because `whileHover` has a `transition` where `out` is `true`, + * when the hover ends, the `duration: 1` transition will be used, + * with no `delay`. + * + * @default false + */ + out?: boolean } export interface ValueAnimationTransition diff --git a/packages/framer-motion/src/motion/__tests__/transition-out.test.tsx b/packages/framer-motion/src/motion/__tests__/transition-out.test.tsx new file mode 100644 index 0000000000..c79005d575 --- /dev/null +++ b/packages/framer-motion/src/motion/__tests__/transition-out.test.tsx @@ -0,0 +1,100 @@ +import { + render, + pointerDown, + pointerEnter, + pointerLeave, + pointerUp, +} from "../../../jest.setup" +import { motion, motionValue } from "../../" +import { nextFrame } from "../../gestures/__tests__/utils" + +describe("transition.out", () => { + it("uses whileHover transition when exiting hover state", async () => { + const opacity = motionValue(0) + + const { container } = render( + + ) + + // Enter hover + pointerEnter(container.firstChild as Element) + + await nextFrame() + await nextFrame() + + expect(opacity.get()).toBe(1) + + // Exit hover - should use whileHover transition with type: false + pointerLeave(container.firstChild as Element) + + // Wait for animation to complete + await nextFrame() + await nextFrame() + + expect(opacity.get()).toBe(0) + }) + + it("uses whileTap out transition when tap ends before hover", async () => { + const scale = motionValue(1) + + const { container } = render( + + ) + + // Enter hover + pointerEnter(container.firstChild as Element) + + await nextFrame() + + // Start tap + pointerDown(container.firstChild as Element) + + await nextFrame() + await nextFrame() + + expect(scale.get()).toBe(0.9) + + // End tap before ending hover + pointerUp(container.firstChild as Element) + + await nextFrame() + + // Wait a frame to ensure animation has completed + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Should use whileTap out transition which is instant + expect(scale.get()).toBe(1.1) + + // Leave hover + pointerLeave(container.firstChild as Element) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(scale.get()).not.toBe(1) + }) +}) diff --git a/packages/framer-motion/src/value/index.ts b/packages/framer-motion/src/value/index.ts index fbe37990ab..e9941b4502 100644 --- a/packages/framer-motion/src/value/index.ts +++ b/packages/framer-motion/src/value/index.ts @@ -2,7 +2,7 @@ import { frame } from "../frameloop" import { SubscriptionManager } from "../utils/subscription-manager" import { velocityPerSecond } from "../utils/velocity-per-second" import { warnOnce } from "../utils/warn-once" -import { AnimationPlaybackControls } from "../animation/types" +import { AnimationPlaybackControls, Transition } from "../animation/types" import { time } from "../frameloop/sync-time" export type Transformer = (v: T) => T @@ -99,6 +99,12 @@ export class MotionValue { */ prevUpdatedAt: number | undefined + /** + * A transition that should be applied to the next animation. + * This is used to handle the `out` transition option. + */ + nextTransition?: Transition + /** * Add a passive effect to this `MotionValue`. *