Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
411 changes: 411 additions & 0 deletions dev/next/app/arcs/page.tsx

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions dev/react/src/tests/transition-arc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { LayoutGroup, motion } from "framer-motion"
import { useState } from "react"

const ITEM_A = { left: 50, top: 200, width: 100, height: 50 }
const ITEM_B = { left: 450, top: 200, width: 100, height: 50 }
const ITEM_B_NEAR = { left: 60, top: 200, width: 100, height: 50 }

export const App = () => {
const params = new URLSearchParams(window.location.search)
const variant = params.get("variant") || "arc"
const [active, setActive] = useState("a")

const isSmall = variant === "small"
const itemB = isSmall ? ITEM_B_NEAR : ITEM_B

/**
* Place arc and ease at the top level so getValueTransition("layout")
* picks them both up (it falls back to the full transition when no
* "layout" key is present). This mirrors how layout.tsx freezes at 50%.
*/
const transition =
variant === "none"
? { duration: 4, ease: () => 0.5 }
: { duration: 4, ease: () => 0.5, arc: { amplitude: 1 } }

return (
<div
id="container"
style={{ position: "relative", width: "100vw", height: "100vh" }}
>
<button
id="toggle"
onClick={() => setActive(active === "a" ? "b" : "a")}
style={{ position: "fixed", top: 16, left: 16 }}
>
Toggle
</button>
<LayoutGroup id="arc-test">
<div id="item-a" style={{ position: "absolute", ...ITEM_A }}>
{active === "a" && (
<motion.div
id="indicator"
layoutId="indicator"
transition={transition}
style={{
width: 100,
height: 100,
background: "red",
}}
/>
)}
</div>
<div id="item-b" style={{ position: "absolute", ...itemB }}>
{active === "b" && (
<motion.div
id="indicator"
layoutId="indicator"
transition={transition}
style={{
width: 100,
height: 100,
background: "red",
}}
/>
)}
</div>
</LayoutGroup>
</div>
)
}
76 changes: 76 additions & 0 deletions packages/framer-motion/cypress/integration/transition-arc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Tests for the arc feature on layout animations (transition.layout.arc).
*
* The test page uses `ease: () => 0.5` inside `transition.layout` to freeze
* the animation at exactly 50% progress, making it easy to sample the
* mid-arc position without fighting timing.
*
* Setup:
* item-a: left=50, top=200, width=100, height=50
* item-b: left=450, top=200, width=100, height=50 (400px apart, same top)
* item-b (small variant): left=60 — only 10px apart, below 20px threshold
*
* With amplitude=1 and 400px horizontal travel, the perpendicular displacement
* at t=0.5 is ≈200px, so the indicator top should be near 0 or 400 (not 200).
*/

describe("layout arc", () => {
it("deviates from the straight-line path mid-animation", () => {
cy.visit("?test=transition-arc")
.wait(50)
// Confirm starting position
.get("#indicator")
.should(([$el]: any) => {
const { top } = $el.getBoundingClientRect()
expect(top).to.be.closeTo(200, 10)
})
// Trigger shared layout animation
.get("#toggle")
.click()
.wait(100)
// At 50% progress the arc displaces the element ~200px from baseline
.get("#indicator")
.should(([$el]: any) => {
const { top } = $el.getBoundingClientRect()
expect(Math.abs(top - 200)).to.be.greaterThan(80)
})
})

it("stays on the straight-line path without arc config", () => {
cy.visit("?test=transition-arc&variant=none")
.wait(50)
.get("#indicator")
.should(([$el]: any) => {
const { top } = $el.getBoundingClientRect()
expect(top).to.be.closeTo(200, 10)
})
.get("#toggle")
.click()
.wait(100)
// No arc — y stays at ≈200 throughout
.get("#indicator")
.should(([$el]: any) => {
const { top } = $el.getBoundingClientRect()
expect(top).to.be.closeTo(200, 20)
})
})

it("does not arc for movements below the 20px minimum distance", () => {
cy.visit("?test=transition-arc&variant=small")
.wait(50)
.get("#indicator")
.should(([$el]: any) => {
const { top } = $el.getBoundingClientRect()
expect(top).to.be.closeTo(200, 10)
})
.get("#toggle")
.click()
.wait(100)
// 10px movement — below threshold, stays linear
.get("#indicator")
.should(([$el]: any) => {
const { top } = $el.getBoundingClientRect()
expect(top).to.be.closeTo(200, 20)
})
})
})
2 changes: 1 addition & 1 deletion packages/framer-motion/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export type {
MotionTransform,
VariantLabels,
} from "./motion/types"
export type { IProjectionNode } from "motion-dom"
export type { Arc, IProjectionNode } from "motion-dom"
export type { DOMMotionComponents } from "./render/dom/types"
export type { ForwardRefComponent, HTMLMotionProps } from "./render/html/types"
export type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export function useVisualElement<
*/
if (visualElement && isMounted.current) {
visualElement.update(props, presenceContext)

}
})

Expand Down
118 changes: 118 additions & 0 deletions packages/motion-dom/src/animation/interfaces/visual-element-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import { setTarget } from "../../render/utils/setters"
import { addValueToWillChange } from "../../value/will-change/add-will-change"
import { getOptimisedAppearId } from "../optimized-appear/get-appear-id"
import { animateMotionValue } from "./motion-value"
import {
bezierPoint,
bezierTangentAngle,
computeArcControlPoint,
normalizeAngle,
resolveArcAmplitude,
} from "../utils/arc"
import { motionValue } from "../../value"
import type { Arc } from "../types"
import type { VisualElementAnimationOptions } from "./types"
import type { AnimationPlaybackControlsWithThen } from "../types"
import type { TargetAndTransition } from "../../node/types"
Expand Down Expand Up @@ -56,6 +65,115 @@ export function animateTarget(
visualElement.animationState &&
visualElement.animationState.getState()[type]

const arc = (transition as any)?.arc as Arc | undefined
if (arc && ("x" in target || "y" in target)) {
const xValue = visualElement.getValue(
"x",
visualElement.latestValues["x"] ?? 0
)
const yValue = visualElement.getValue(
"y",
visualElement.latestValues["y"] ?? 0
)

const xRaw = target.x as number | number[] | undefined
const yRaw = target.y as number | number[] | undefined

const xFrom = (Array.isArray(xRaw) && xRaw[0] != null
? xRaw[0]
: xValue?.get()) as number ?? 0
const yFrom = (Array.isArray(yRaw) && yRaw[0] != null
? yRaw[0]
: yValue?.get()) as number ?? 0
const xTo = (Array.isArray(xRaw)
? xRaw[xRaw.length - 1]
: xRaw ?? xFrom) as number
const yTo = (Array.isArray(yRaw)
? yRaw[yRaw.length - 1]
: yRaw ?? yFrom) as number

const amplitude = resolveArcAmplitude(arc, xTo - xFrom, yTo - yFrom)
const control = computeArcControlPoint(
xFrom,
yFrom,
xTo,
yTo,
amplitude,
arc.peak ?? 0.5
)

const rotationScale =
arc.orientToPath === true
? 0.5
: typeof arc.orientToPath === "number"
? arc.orientToPath
: 0
const rotateValue = rotationScale
? visualElement.getValue(
"rotate",
visualElement.latestValues["rotate"] ?? 0
)
: undefined
const baseRotation = rotateValue
? ((rotateValue.get() as number) ?? 0)
: 0

// Pre-compute start/end tangent angles so we can normalize
// the rotation to 0 at both endpoints (no jump in/out)
const tangentAt0 = rotateValue
? bezierTangentAngle(0, xFrom, control.x, xTo, yFrom, control.y, yTo)
: 0
const tangentAt1 = rotateValue
? bezierTangentAngle(1, xFrom, control.x, xTo, yFrom, control.y, yTo)
: 0

const arcTransition = {
delay,
...getValueTransition(transition || {}, "x"),
}
delete (arcTransition as any).arc

const progress = motionValue(0)
progress.start(
animateMotionValue("", progress, [0, 1000] as any, {
...arcTransition,
isSync: true,
velocity: 0,
onUpdate: (latest: number) => {
const t = latest / 1000
xValue?.set(bezierPoint(t, xFrom, control.x, xTo))
yValue?.set(bezierPoint(t, yFrom, control.y, yTo))
if (rotateValue) {
const raw = bezierTangentAngle(
t,
xFrom, control.x, xTo,
yFrom, control.y, yTo
)
const baseline =
tangentAt0 +
normalizeAngle(tangentAt1 - tangentAt0) * t
rotateValue.set(
baseRotation +
normalizeAngle(raw - baseline) *
rotationScale
)
}
},
onComplete: () => {
xValue?.set(xTo)
yValue?.set(yTo)
rotateValue?.set(baseRotation)
},
})
)

if (progress.animation) animations.push(progress.animation)

delete (target as any).x
delete (target as any).y
if (arc.orientToPath) delete (target as any).rotate
}

for (const key in target) {
const value = visualElement.getValue(
key,
Expand Down
50 changes: 50 additions & 0 deletions packages/motion-dom/src/animation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,19 @@ export interface ValueTransition
* @public
*/
inherit?: boolean

/**
* Configures an arc path for animations. The element will travel
* along a curved path rather than a straight line between its old and
* new positions.
*
* Can be used in keyframe animations (`transition.arc`) and layout
* animations (`transition.layout.arc`), including with `useAnimate`.
*
* @public
*/
arc?: Arc

}

/**
Expand Down Expand Up @@ -595,6 +608,43 @@ export type Transition<V = any> =
| ValueAnimationTransition<V>
| TransitionWithValueOverrides<V>

export interface Arc {
/**
* How far the arc bulges perpendicular to the straight-line path,
* as a fraction of the total distance. A value of `1` means the arc
* peaks at a height equal to the full travel distance. Should be >= 0;
* use `direction` to control which side the arc bulges toward.
*/
amplitude: number
/**
* Where along the path (0–1) the arc reaches its maximum height.
* `0.5` (the default) produces a symmetric arc; lower values peak
* earlier, higher values peak later.
*
* Default: `0.5`
*/
peak?: number
/**
* Controls which side of the straight-line path the arc bulges toward,
* relative to the direction of travel.
*
* - `"cw"` — the arc bulges clockwise relative to the direction of travel.
* - `"ccw"` — the arc bulges counterclockwise relative to the direction of travel.
*
* When unset, the side is chosen automatically so the arc always bulges
* toward the same screen side regardless of movement direction.
*/
direction?: "cw" | "ccw"
/**
* Rotates the element to follow the tangent of the arc path.
*
* - `true` — follow with a default intensity of `0.5`
* - `number` (0–1) — scale factor for the tangent rotation.
* `0` = no rotation, `1` = full tangent following.
*/
orientToPath?: boolean | number
}

export type DynamicOption<T> = (i: number, total: number) => T

export type ValueAnimationWithDynamicDelay = Omit<
Expand Down
Loading