diff --git a/dev/examples/Animation-reverse.tsx b/dev/examples/Animation-reverse.tsx
new file mode 100644
index 0000000000..8c2974ae59
--- /dev/null
+++ b/dev/examples/Animation-reverse.tsx
@@ -0,0 +1,29 @@
+import { useAnimate } from "framer-motion"
+import * as React from "react"
+
+export const App = () => {
+ const [scope, animate] = useAnimate()
+
+ return (
+
+
+
reverse
+
+
+ )
+}
diff --git a/dev/tests/animate-reverse.tsx b/dev/tests/animate-reverse.tsx
new file mode 100644
index 0000000000..a29f1594da
--- /dev/null
+++ b/dev/tests/animate-reverse.tsx
@@ -0,0 +1,53 @@
+import { motion, animate } from "framer-motion"
+import * as React from "react"
+import { useEffect, useState } from "react"
+import styled from "styled-components"
+
+const Container = styled.section`
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ padding: 100px;
+
+ div {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+`
+
+export const App = () => {
+ const [count, setCount] = useState(0)
+ const [result, setResult] = useState("")
+
+ useEffect(() => {
+ if (count % 2 === 0) return
+
+ const output: number[] = []
+ const controls = animate(0, 100, {
+ duration: 0.5,
+ ease: "linear",
+ onUpdate: (v: number) => output.push(v),
+ onComplete: () =>
+ setResult(
+ output[1] === 100 && output.length !== 2
+ ? "Success"
+ : "Fail"
+ ),
+ })
+ controls.time = controls.duration
+ controls.speed = -1
+
+ return controls.stop
+ }, [count])
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/packages/framer-motion/cypress/integration/animate-reverse.ts b/packages/framer-motion/cypress/integration/animate-reverse.ts
new file mode 100644
index 0000000000..ff61c97b70
--- /dev/null
+++ b/packages/framer-motion/cypress/integration/animate-reverse.ts
@@ -0,0 +1,13 @@
+describe("animate() x layout prop in reverse speed", () => {
+ it("animate() plays as expected when layout prop is present", () => {
+ cy.visit("?test=animate-reverse")
+ .wait(1000)
+ .get("#action")
+ .trigger("click", 1, 1, { force: true })
+ .wait(600)
+ .get("#result")
+ .should(([$element]: any) => {
+ expect($element.value).to.equal("Success")
+ })
+ })
+})
diff --git a/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts b/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts
index b5737e6a09..7aa74bc67e 100644
--- a/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts
+++ b/packages/framer-motion/src/animation/animators/js/__tests__/animate.test.ts
@@ -1232,6 +1232,45 @@ describe("animate", () => {
expect(output).toEqual([0, 20, 40, 20, 0])
})
+ test("Reverse animation from the end", async () => {
+ const output: number[] = []
+
+ const animation = animateValue({
+ keyframes: [0, 100],
+ driver: syncDriver(20),
+ duration: 100,
+ ease: linear,
+ onUpdate: (v) => {
+ output.push(v)
+ },
+ })
+ animation.time = 100
+ animation.speed = -1
+
+ await animation
+
+ expect(output).toEqual([100, 80, 60, 40, 20, 0])
+ })
+
+ test("Reverse animation from the end with half speed", async () => {
+ const output: number[] = []
+
+ const animation = animateValue({
+ keyframes: [0, 100],
+ driver: syncDriver(20),
+ duration: 100,
+ ease: linear,
+ onUpdate: (v) => {
+ output.push(v)
+ },
+ })
+ animation.time = 100
+ animation.speed = -0.5
+
+ await animation
+ expect(output).toEqual([100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0])
+ })
+
test("Correctly ends animations with duration: 0", async () => {
const animation = animateValue({
keyframes: [0, 100],
diff --git a/packages/framer-motion/src/animation/animators/js/index.ts b/packages/framer-motion/src/animation/animators/js/index.ts
index 1ca5e6878a..c30b3e2c96 100644
--- a/packages/framer-motion/src/animation/animators/js/index.ts
+++ b/packages/framer-motion/src/animation/animators/js/index.ts
@@ -143,16 +143,22 @@ export function animateValue({
* a pending operation that gets resolved here.
*/
if (speed > 0) startTime = Math.min(startTime, timestamp)
+ if (speed < 0)
+ startTime = Math.min(timestamp - totalDuration / speed, startTime)
if (holdTime !== null) {
currentTime = holdTime
} else {
- currentTime = (timestamp - startTime) * speed
+ // Rounding the time because floating point arithmetic is not always accurate, e.g. 3000.367 - 1000.367 =
+ // 2000.0000000000002. This is a problem when we are comparing the currentTime with the duration, for
+ // example.
+ currentTime = Math.round(timestamp - startTime) * speed
}
// Rebase on delay
- const timeWithoutDelay = currentTime - delay
- const isInDelayPhase = timeWithoutDelay < 0
+ const timeWithoutDelay = currentTime - delay * (speed >= 0 ? 1 : -1)
+ const isInDelayPhase =
+ speed >= 0 ? timeWithoutDelay < 0 : timeWithoutDelay > totalDuration
currentTime = Math.max(timeWithoutDelay, 0)
/**
@@ -240,14 +246,12 @@ export function animateValue({
let { done } = state
if (!isInDelayPhase && calculatedDuration !== null) {
- done = currentTime >= totalDuration
+ done = speed >= 0 ? currentTime >= totalDuration : currentTime <= 0
}
const isAnimationFinished =
holdTime === null &&
- (playState === "finished" ||
- (playState === "running" && done) ||
- (speed < 0 && currentTime <= 0))
+ (playState === "finished" || (playState === "running" && done))
if (onUpdate) {
onUpdate(state.value)