From 84b754105002af2f927992346307afa0fa282da9 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 27 Sep 2025 00:46:22 +1000 Subject: [PATCH 01/20] add layoutCurve prop --- .../src/motion/utils/use-visual-element.ts | 2 ++ packages/framer-motion/src/projection/node/types.ts | 3 +++ packages/motion-dom/src/node/types.ts | 11 +++++++++++ 3 files changed, 16 insertions(+) diff --git a/packages/framer-motion/src/motion/utils/use-visual-element.ts b/packages/framer-motion/src/motion/utils/use-visual-element.ts index ff802ee402..270e048dc3 100644 --- a/packages/framer-motion/src/motion/utils/use-visual-element.ts +++ b/packages/framer-motion/src/motion/utils/use-visual-element.ts @@ -172,6 +172,7 @@ function createProjectionNode( layoutScroll, layoutRoot, layoutCrossfade, + layoutCurve, } = props visualElement.projection = new ProjectionNodeConstructor( @@ -197,6 +198,7 @@ function createProjectionNode( animationType: typeof layout === "string" ? layout : "both", initialPromotionConfig, crossfade: layoutCrossfade, + layoutCurve, layoutScroll, layoutRoot, }) diff --git a/packages/framer-motion/src/projection/node/types.ts b/packages/framer-motion/src/projection/node/types.ts index 546fd75f12..8cf32f0919 100644 --- a/packages/framer-motion/src/projection/node/types.ts +++ b/packages/framer-motion/src/projection/node/types.ts @@ -173,6 +173,9 @@ export interface ProjectionNodeOptions { layout?: boolean | string visualElement?: VisualElement crossfade?: boolean + layoutCurve?: { + amplitude: number + } transition?: Transition initialPromotionConfig?: InitialPromotionConfig } diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index d5aa01b977..18b540efc1 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -966,6 +966,17 @@ export interface MotionNodeLayoutOptions { * to `false`, this element will take its default opacity throughout the animation. */ layoutCrossfade?: boolean + + /** + * By default, layout animations animate from a straight line between the two bounding boxes. + * By setting this to a number, the animation will animate along a curve with the given + * amplitude. + * + * @public + */ + layoutCurve?: { + amplitude: number + } } /** From 24e9017c65f7e0a0cc4c94e56a0709b352f180ca Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 27 Sep 2025 00:50:24 +1000 Subject: [PATCH 02/20] add: layout curve calculations --- .../projection/node/create-projection-node.ts | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/framer-motion/src/projection/node/create-projection-node.ts b/packages/framer-motion/src/projection/node/create-projection-node.ts index adcf738bcc..0b82a8158e 100644 --- a/packages/framer-motion/src/projection/node/create-projection-node.ts +++ b/packages/framer-motion/src/projection/node/create-projection-node.ts @@ -1514,6 +1514,33 @@ export function createProjectionNode({ this.projectionDeltaWithTransform = createDelta() } + computeControlPoints( + originX: number, + originY: number, + targetX: number, + targetY: number, + amplitude: number + ) { + const x = -(targetY - originY) + const y = targetX - originX + const length = Math.sqrt(x * x + y * y) + + if (length > 0) { + const normalX = x / length + const normalY = y / length + + const midX = originX + (targetX - originX) * 0.5 + const midY = originY + (targetY - originY) * 0.5 + + return { + x: midX + normalX * amplitude, + y: midY + normalY * amplitude, + } + } else { + return { x: originX, y: originY } + } + } + /** * Animation */ @@ -1561,8 +1588,27 @@ export function createProjectionNode({ this.mixTargetDelta = (latest: number) => { const progress = latest / 1000 - mixAxisDelta(targetDelta.x, delta.x, progress) - mixAxisDelta(targetDelta.y, delta.y, progress) + const controlDelta = this.options.layoutCurve + ? this.computeControlPoints( + delta.x.translate, + delta.y.translate, + 0, + 0, + this.options.layoutCurve?.amplitude ?? 0 + ) + : { + x: 0, + y: 0, + } + + // deltaTarget = target + // delta = origin + + mixAxisDelta(targetDelta.x, delta.x, controlDelta.x, progress) + mixAxisDelta(targetDelta.y, delta.y, controlDelta.y, progress) + + // targetDelta now = interpolated + this.setTargetDelta(targetDelta) if ( @@ -2270,8 +2316,26 @@ function removeLeadSnapshots(stack: NodeStack) { stack.removeLeadSnapshot() } -export function mixAxisDelta(output: AxisDelta, delta: AxisDelta, p: number) { - output.translate = mixNumber(delta.translate, 0, p) +function bezierPoint( + t: number, + origin: number, + control: number, + target: number +) { + return ( + Math.pow(1 - t, 2) * origin + + 2 * (1 - t) * t * control + + Math.pow(t, 2) * target + ) +} + +export function mixAxisDelta( + output: AxisDelta, + delta: AxisDelta, + control: number, + p: number +) { + output.translate = bezierPoint(p, delta.translate, control, 0) output.scale = mixNumber(delta.scale, 1, p) output.origin = delta.origin output.originPoint = delta.originPoint From 58181ed01b44852821d4be0d9bbefda27538208b Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 27 Sep 2025 00:50:31 +1000 Subject: [PATCH 03/20] create layoutCurve example --- dev/next/app/layout-curve/page.tsx | 142 +++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 dev/next/app/layout-curve/page.tsx diff --git a/dev/next/app/layout-curve/page.tsx b/dev/next/app/layout-curve/page.tsx new file mode 100644 index 0000000000..d5f40901bb --- /dev/null +++ b/dev/next/app/layout-curve/page.tsx @@ -0,0 +1,142 @@ +"use client" +import { cancelFrame, frame, LayoutGroup, motion } from "motion/react" +import { useEffect, useState } from "react" + +function NavigationItem({ + title, + current, + onClick, + id, + layoutCurveAmplitude, +}: { + title: string + current?: boolean + onClick?: () => void + id: string + layoutCurveAmplitude?: number +}) { + return ( +
+ {current && ( + + )} + +
+ ) +} + +export default function Page() { + const [state, setState] = useState("a") + const [layoutCurveAmplitude, setCurveAmplitude] = useState(500) + + useEffect(() => { + let prevLeft = 0 + const check = frame.setup(() => { + const indicator = document.getElementById("current-indicator") + if (!indicator) return + + const { left } = indicator.getBoundingClientRect() + + if (Math.abs(left - prevLeft) > 100) { + // console.log(prevLeft, left) + } + + prevLeft = left + }, true) + + return () => cancelFrame(check) + }, [state]) + + return ( +
+
+ + setCurveAmplitude(Number(e.target.value))} + /> +
+
+
+ + setState("a")} + layoutCurveAmplitude={layoutCurveAmplitude} + /> + + setState("b")} + layoutCurveAmplitude={layoutCurveAmplitude} + /> + +
+
+
+ ) +} From ac09138f688c4803ee03f25b06afe47c54f87e32 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sun, 5 Oct 2025 06:52:44 +1100 Subject: [PATCH 04/20] change amplitude logic --- dev/next/app/layout-curve/page.tsx | 99 ++++++++++++++----- .../projection/node/create-projection-node.ts | 31 +++--- 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/dev/next/app/layout-curve/page.tsx b/dev/next/app/layout-curve/page.tsx index d5f40901bb..fd5ff265ab 100644 --- a/dev/next/app/layout-curve/page.tsx +++ b/dev/next/app/layout-curve/page.tsx @@ -1,6 +1,6 @@ "use client" -import { cancelFrame, frame, LayoutGroup, motion } from "motion/react" -import { useEffect, useState } from "react" +import { LayoutGroup, motion } from "motion/react" +import { useState } from "react" function NavigationItem({ title, @@ -55,25 +55,7 @@ function NavigationItem({ export default function Page() { const [state, setState] = useState("a") - const [layoutCurveAmplitude, setCurveAmplitude] = useState(500) - - useEffect(() => { - let prevLeft = 0 - const check = frame.setup(() => { - const indicator = document.getElementById("current-indicator") - if (!indicator) return - - const { left } = indicator.getBoundingClientRect() - - if (Math.abs(left - prevLeft) > 100) { - // console.log(prevLeft, left) - } - - prevLeft = left - }, true) - - return () => cancelFrame(check) - }, [state]) + const [layoutCurveAmplitude, setCurveAmplitude] = useState(1) return (
setCurveAmplitude(Number(e.target.value))} /> @@ -137,6 +120,76 @@ export default function Page() {
+
+
+ + setState("a")} + layoutCurveAmplitude={layoutCurveAmplitude} + /> + + setState("b")} + layoutCurveAmplitude={layoutCurveAmplitude} + /> + +
+
+
+
+ +
+ setState("a")} + layoutCurveAmplitude={layoutCurveAmplitude} + /> +
+ + setState("b")} + layoutCurveAmplitude={layoutCurveAmplitude} + /> +
+
+
) } diff --git a/packages/framer-motion/src/projection/node/create-projection-node.ts b/packages/framer-motion/src/projection/node/create-projection-node.ts index 0b82a8158e..ed97a7607f 100644 --- a/packages/framer-motion/src/projection/node/create-projection-node.ts +++ b/packages/framer-motion/src/projection/node/create-projection-node.ts @@ -1521,20 +1521,26 @@ export function createProjectionNode({ targetY: number, amplitude: number ) { - const x = -(targetY - originY) - const y = targetX - originX - const length = Math.sqrt(x * x + y * y) + const deltaX = targetX - originX + const deltaY = targetY - originY - if (length > 0) { - const normalX = x / length - const normalY = y / length + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) - const midX = originX + (targetX - originX) * 0.5 - const midY = originY + (targetY - originY) * 0.5 + if (distance > 0) { + const perpX = -deltaY + const perpY = deltaX + + const normalPerpX = perpX / distance + const normalPerpY = perpY / distance + + const midX = originX + deltaX * 0.5 + const midY = originY + deltaY * 0.5 + + const desiredHeight = amplitude * distance return { - x: midX + normalX * amplitude, - y: midY + normalY * amplitude, + x: midX + normalPerpX * desiredHeight, + y: midY + normalPerpY * desiredHeight, } } else { return { x: originX, y: originY } @@ -1588,13 +1594,16 @@ export function createProjectionNode({ this.mixTargetDelta = (latest: number) => { const progress = latest / 1000 + let amplitude = this.options.layoutCurve?.amplitude ?? 0 + if (delta.x.translate <= 0) amplitude *= -1 + const controlDelta = this.options.layoutCurve ? this.computeControlPoints( delta.x.translate, delta.y.translate, 0, 0, - this.options.layoutCurve?.amplitude ?? 0 + amplitude ) : { x: 0, From a93fdcf134bbde70cd5d4e9dcfb1130fc7f47271 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Thu, 12 Mar 2026 03:26:08 +1100 Subject: [PATCH 05/20] arc --- dev/next/app/layout-curve/page.tsx | 128 +++++++++++++----- .../src/motion/utils/use-visual-element.ts | 11 +- packages/motion-dom/src/animation/types.ts | 13 ++ packages/motion-dom/src/node/types.ts | 22 +-- .../projection/node/create-projection-node.ts | 70 +++++++--- .../motion-dom/src/projection/node/types.ts | 6 +- 6 files changed, 178 insertions(+), 72 deletions(-) diff --git a/dev/next/app/layout-curve/page.tsx b/dev/next/app/layout-curve/page.tsx index fd5ff265ab..6e3b99e8f0 100644 --- a/dev/next/app/layout-curve/page.tsx +++ b/dev/next/app/layout-curve/page.tsx @@ -1,19 +1,17 @@ "use client" -import { LayoutGroup, motion } from "motion/react" +import { Arc, LayoutGroup, motion } from "motion/react" import { useState } from "react" function NavigationItem({ title, - current, - onClick, id, - layoutCurveAmplitude, + arc, + isActive, }: { title: string - current?: boolean - onClick?: () => void id: string - layoutCurveAmplitude?: number + arc: Arc + isActive?: boolean }) { return (
- {current && ( + {isActive && ( )} - +
) } export default function Page() { const [state, setState] = useState("a") - const [layoutCurveAmplitude, setCurveAmplitude] = useState(1) + const [layoutArc, setLayoutArc] = useState({ amplitude: 1, peak: 0.5 }) return (
+ setCurveAmplitude(Number(e.target.value))} + value={layoutArc.amplitude} + onChange={(e) => + setLayoutArc({ + ...layoutArc, + amplitude: Number(e.target.value), + }) + } + /> + + + setLayoutArc({ + ...layoutArc, + peak: Number(e.target.value), + }) + } /> + + +
setState("a")} - layoutCurveAmplitude={layoutCurveAmplitude} + isActive={state === "a"} + arc={layoutArc} /> setState("b")} - layoutCurveAmplitude={layoutCurveAmplitude} + isActive={state === "b"} + arc={layoutArc} />
@@ -139,17 +199,15 @@ export default function Page() { setState("a")} - layoutCurveAmplitude={layoutCurveAmplitude} + isActive={state === "a"} + arc={layoutArc} /> setState("b")} - layoutCurveAmplitude={layoutCurveAmplitude} + isActive={state === "b"} + arc={layoutArc} /> @@ -174,18 +232,16 @@ export default function Page() { setState("a")} - layoutCurveAmplitude={layoutCurveAmplitude} + isActive={state === "a"} + arc={layoutArc} /> setState("b")} - layoutCurveAmplitude={layoutCurveAmplitude} + isActive={state === "b"} + arc={layoutArc} /> diff --git a/packages/framer-motion/src/motion/utils/use-visual-element.ts b/packages/framer-motion/src/motion/utils/use-visual-element.ts index 143ade0908..509b115dcb 100644 --- a/packages/framer-motion/src/motion/utils/use-visual-element.ts +++ b/packages/framer-motion/src/motion/utils/use-visual-element.ts @@ -116,6 +116,13 @@ export function useVisualElement< */ if (visualElement && isMounted.current) { visualElement.update(props, presenceContext) + + if (visualElement.projection) { + visualElement.projection.setOptions({ + ...visualElement.projection.options, + layoutArc: props.layoutArc, + }) + } } }) @@ -202,7 +209,7 @@ function createProjectionNode( layoutScroll, layoutRoot, layoutCrossfade, - layoutCurve, + layoutArc, } = props visualElement.projection = new ProjectionNodeConstructor( @@ -228,7 +235,7 @@ function createProjectionNode( animationType: typeof layout === "string" ? layout : "both", initialPromotionConfig, crossfade: layoutCrossfade, - layoutCurve, + layoutArc, layoutScroll, layoutRoot, }) diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index 801584dade..b898e17ed4 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -595,6 +595,19 @@ export type Transition = | ValueAnimationTransition | TransitionWithValueOverrides +export interface Arc { + amplitude: number + peak: number + /** + * Controls which side of the straight-line path the arc bulges toward. + * + * `1` arcs one way, `-1` arcs the other. When unset, the direction + * automatically reverses based on the dominant axis of movement so the + * arc feels natural in both directions. + */ + direction?: 1 | -1 +} + export type DynamicOption = (i: number, total: number) => T export type ValueAnimationWithDynamicDelay = Omit< diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index 411e52f041..bdd03a8feb 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -1,6 +1,7 @@ import type { BoundingBox, Box, Point } from "motion-utils" import type { AnyResolvedKeyframe, + Arc, InertiaOptions, Target, TransformProperties, @@ -72,7 +73,7 @@ export interface Variants { /** * @deprecated */ -export type LegacyAnimationControls = { +export interface LegacyAnimationControls { /** * Subscribes a component's animation controls to this. * @@ -538,7 +539,6 @@ export interface MotionNodeTapHandlers { * Note: This is not supported publically. */ globalTapTarget?: boolean - } /** @@ -970,15 +970,21 @@ export interface MotionNodeLayoutOptions { layoutCrossfade?: boolean /** - * By default, layout animations animate from a straight line between the two bounding boxes. - * By setting this to a number, the animation will animate along a curve with the given - * amplitude. + * By default, layout animations animate along a straight line between the two bounding boxes. + * Setting this prop makes the animation travel along a curved (quadratic Bezier) arc. + * + * - `layoutArc={{ amplitude: 1, peak: 0.5 }}` — object form with symmetric arc + * - `layoutArc={{ amplitude: 0.7, peak: 0.3 }}` — object form with asymmetric arc + * - `layoutArc={{ amplitude: 0.7, direction: -1 }}` — force arc to the other side + * + * `amplitude` controls how far the arc bulges perpendicular to the straight-line path. + * `peak` (0-1, default 0.5) shifts where along the path the arc reaches its maximum height. + * `direction` (`1` or `-1`) overrides the automatic arc side. When omitted, the arc + * direction reverses automatically based on the direction of movement. * * @public */ - layoutCurve?: { - amplitude: number - } + layoutArc?: Arc } /** diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 8370ff2c18..fad01586fd 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -1549,7 +1549,8 @@ export function createProjectionNode({ originY: number, targetX: number, targetY: number, - amplitude: number + amplitude: number, + peak: number ) { const deltaX = targetX - originX const deltaY = targetY - originY @@ -1563,8 +1564,8 @@ export function createProjectionNode({ const normalPerpX = perpX / distance const normalPerpY = perpY / distance - const midX = originX + deltaX * 0.5 - const midY = originY + deltaY * 0.5 + const midX = originX + deltaX * peak + const midY = originY + deltaY * peak const desiredHeight = amplitude * distance @@ -1624,27 +1625,45 @@ export function createProjectionNode({ this.mixTargetDelta = (latest: number) => { const progress = latest / 1000 - let amplitude = this.options.layoutCurve?.amplitude ?? 0 - if (delta.x.translate <= 0) amplitude *= -1 + const layoutArc = this.options.layoutArc - const controlDelta = this.options.layoutCurve - ? this.computeControlPoints( - delta.x.translate, - delta.y.translate, - 0, - 0, - amplitude - ) - : { - x: 0, - y: 0, - } - - // deltaTarget = target - // delta = origin + if (layoutArc) { + let amplitude = layoutArc.amplitude + if (layoutArc.direction) { + amplitude *= layoutArc.direction + } else { + const dominantDelta = + Math.abs(delta.x.translate) >= + Math.abs(delta.y.translate) + ? delta.x.translate + : delta.y.translate + if (dominantDelta < 0) amplitude *= -1 + } - mixAxisDelta(targetDelta.x, delta.x, controlDelta.x, progress) - mixAxisDelta(targetDelta.y, delta.y, controlDelta.y, progress) + const controlDelta = this.computeControlPoints( + delta.x.translate, + delta.y.translate, + 0, + 0, + amplitude, + layoutArc.peak + ) + mixAxisDelta( + targetDelta.x, + delta.x, + controlDelta.x, + progress + ) + mixAxisDelta( + targetDelta.y, + delta.y, + controlDelta.y, + progress + ) + } else { + mixAxisDeltaLinear(targetDelta.x, delta.x, progress) + mixAxisDeltaLinear(targetDelta.y, delta.y, progress) + } // targetDelta now = interpolated @@ -2369,6 +2388,13 @@ function bezierPoint( ) } +function mixAxisDeltaLinear(output: AxisDelta, delta: AxisDelta, p: number) { + output.translate = mixNumber(delta.translate, 0, p) + output.scale = mixNumber(delta.scale, 1, p) + output.origin = delta.origin + output.originPoint = delta.originPoint +} + export function mixAxisDelta( output: AxisDelta, delta: AxisDelta, diff --git a/packages/motion-dom/src/projection/node/types.ts b/packages/motion-dom/src/projection/node/types.ts index 5a27976fba..018a450360 100644 --- a/packages/motion-dom/src/projection/node/types.ts +++ b/packages/motion-dom/src/projection/node/types.ts @@ -1,5 +1,5 @@ import type { JSAnimation } from "../../animation/JSAnimation" -import type { Transition, ValueTransition } from "../../animation/types" +import type { Arc, Transition, ValueTransition } from "../../animation/types" import type { ResolvedValues } from "../../render/types" import type { VisualElement, MotionStyle } from "../../render/VisualElement" import { Box, Delta, Point } from "motion-utils" @@ -191,9 +191,7 @@ export interface ProjectionNodeOptions { layout?: boolean | string visualElement?: VisualElement crossfade?: boolean - layoutCurve?: { - amplitude: number - } + layoutArc?: Arc transition?: Transition initialPromotionConfig?: InitialPromotionConfig layoutDependency?: unknown From 5c5266ff3c2130ee50809d67ff70851d0943486f Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 01:49:56 +1100 Subject: [PATCH 06/20] cleanup --- dev/next/app/layout-curve/page.tsx | 66 ++++++++------- packages/motion-dom/src/animation/types.ts | 27 +++++-- .../projection/node/create-projection-node.ts | 81 +++++++++++++++---- .../motion-dom/src/projection/node/types.ts | 2 +- 4 files changed, 123 insertions(+), 53 deletions(-) diff --git a/dev/next/app/layout-curve/page.tsx b/dev/next/app/layout-curve/page.tsx index 6e3b99e8f0..1ee525563d 100644 --- a/dev/next/app/layout-curve/page.tsx +++ b/dev/next/app/layout-curve/page.tsx @@ -24,12 +24,11 @@ function NavigationItem({ ({ amplitude: 1, peak: 0.5 }) + const [layoutArc, setLayoutArc] = useState({ amplitude: 1 }) return (
{`{``} setLayoutArc({ @@ -110,13 +112,13 @@ export default function Page() { }) } /> - + setLayoutArc({ ...layoutArc, @@ -129,18 +131,22 @@ export default function Page() { value={layoutArc.direction ?? "auto"} onChange={(e) => { const val = e.target.value - setLayoutArc({ - ...layoutArc, - direction: - val === "auto" - ? undefined - : (Number(val) as 1 | -1), - }) + const direction: Arc["direction"] = + val === "auto" + ? undefined + : val === "1" || val === "-1" + ? (Number(val) as 1 | -1) + : (val as "up" | "down" | "left" | "right") + setLayoutArc({ ...layoutArc, direction }) }} > - - + + + + + +
-
-
+ +
+
+ ) +} + +const Example = ({ + children, + variant, +}: { + children: React.ReactNode + variant?: string +}) => { + return ( +
+ {children} +
+ ) +} + +const Examples = ({ state, arc }: { state: string; arc: Arc }) => { + return ( +
+ + - - + /> + + + + - - -
- -
-
+ + + + + + + + + + + + +
+
+ + + +
+ ) +} + +function NavigationItem({ + title, + id, + arc, + isActive, +}: { + title: string + id: string + arc: Arc + isActive?: boolean +}) { + return ( +
+ {isActive && ( + - - - - - -
-
+ /> + )}
-
- -
- -
- - -
-
+ {title}
) diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index 0957d63195..10b59fc38e 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -624,16 +624,16 @@ export interface Arc { */ peak?: number /** - * Controls which side of the straight-line path the arc bulges toward. + * Controls which side of the straight-line path the arc bulges toward, + * relative to the direction of travel. * - * - `1` / `-1` — relative: flips the automatically-detected side. - * - `"up"` / `"down"` / `"left"` / `"right"` — absolute: the arc always - * bulges in that screen direction regardless of movement direction. + * - `"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 direction automatically reverses based on the dominant - * axis of movement so the arc feels natural in both directions. + * When unset, the side is chosen automatically so the arc always bulges + * toward the same screen side regardless of movement direction. */ - direction?: 1 | -1 | "up" | "down" | "left" | "right" + direction?: "cw" | "ccw" } export type DynamicOption = (i: number, total: number) => T diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 507964f1ce..c1281a37a6 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -1660,17 +1660,9 @@ export function createProjectionNode({ } else { amplitude = layoutArc.amplitude const { direction } = layoutArc - if (direction === 1 || direction === -1) { - amplitude *= direction - } else if (direction) { - const { x, y } = delta - const shouldFlip = - (direction === "up" && x.translate > 0) || - (direction === "down" && x.translate < 0) || - (direction === "left" && y.translate < 0) || - (direction === "right" && y.translate > 0) - if (shouldFlip) amplitude *= -1 - } else { + if (direction === "cw") { + amplitude *= -1 + } else if (!direction) { const dominantDelta = Math.abs(delta.x.translate) >= Math.abs(delta.y.translate) From 1a4c708f5837603f35151968888a4847e0d26921 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:01:30 +1100 Subject: [PATCH 09/20] support keyframe arcs --- dev/next/app/layout-curve/page.tsx | 100 ++++++++++++++++-- .../interfaces/visual-element-target.ts | 74 +++++++++++++ .../motion-dom/src/animation/utils/arc.ts | 60 +++++++++++ .../projection/node/create-projection-node.ts | 71 ++----------- 4 files changed, 234 insertions(+), 71 deletions(-) create mode 100644 packages/motion-dom/src/animation/utils/arc.ts diff --git a/dev/next/app/layout-curve/page.tsx b/dev/next/app/layout-curve/page.tsx index 087ac671be..cfee75b1d7 100644 --- a/dev/next/app/layout-curve/page.tsx +++ b/dev/next/app/layout-curve/page.tsx @@ -1,5 +1,11 @@ "use client" -import { Arc, LayoutGroup, motion } from "motion/react" +import { + Arc, + LayoutGroup, + motion, + TargetAndTransition, + Transition, +} from "motion/react" import { useState } from "react" export default function Page() { @@ -144,23 +150,50 @@ const Example = ({ const Examples = ({ state, arc }: { state: string; arc: Arc }) => { return ( -
- - + + + + + @@ -268,3 +301,48 @@ function NavigationItem({
) } + +const MotionExample = ({ + animate, + transition, + arc, +}: { + animate: TargetAndTransition + transition: Transition + arc: Arc +}) => { + return ( +
+ + +
+ ) +} diff --git a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts index d130f0705f..6c50559aba 100644 --- a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts +++ b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts @@ -6,6 +6,13 @@ 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, + computeArcControlPoint, + 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" @@ -56,6 +63,73 @@ 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 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)) + }, + onComplete: () => { + xValue?.set(xTo) + yValue?.set(yTo) + }, + }) + ) + + if (progress.animation) animations.push(progress.animation) + + delete (target as any).x + delete (target as any).y + } + for (const key in target) { const value = visualElement.getValue( key, diff --git a/packages/motion-dom/src/animation/utils/arc.ts b/packages/motion-dom/src/animation/utils/arc.ts new file mode 100644 index 0000000000..b0981633db --- /dev/null +++ b/packages/motion-dom/src/animation/utils/arc.ts @@ -0,0 +1,60 @@ +import type { Arc } from "../types" + +export function bezierPoint( + t: number, + origin: number, + control: number, + target: number +): number { + return ( + Math.pow(1 - t, 2) * origin + + 2 * (1 - t) * t * control + + Math.pow(t, 2) * target + ) +} + +export function computeArcControlPoint( + fromX: number, + fromY: number, + toX: number, + toY: number, + amplitude: number, + peak: number +): { x: number; y: number } { + const deltaX = toX - fromX + const deltaY = toY - fromY + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) + + if (distance > 0) { + const normalPerpX = -deltaY / distance + const normalPerpY = deltaX / distance + const desiredHeight = amplitude * distance + + return { + x: fromX + deltaX * peak + normalPerpX * desiredHeight, + y: fromY + deltaY * peak + normalPerpY * desiredHeight, + } + } + + return { x: fromX, y: fromY } +} + +export function resolveArcAmplitude( + arc: Arc, + deltaX: number, + deltaY: number +): number { + let amplitude = arc.amplitude + const { direction } = arc + + if (direction === "cw") { + amplitude *= -1 + } else if (!direction) { + const dominantDelta = + Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY + if (dominantDelta < 0) amplitude *= -1 + } + // "ccw": no change + + return amplitude +} diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index c1281a37a6..de1b894080 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -12,6 +12,11 @@ import { animateSingleValue } from "../../animation/animate/single-value" import { JSAnimation } from "../../animation/JSAnimation" import { getOptimisedAppearId } from "../../animation/optimized-appear/get-appear-id" import { Arc, Transition, ValueAnimationOptions } from "../../animation/types" +import { + bezierPoint, + computeArcControlPoint, + resolveArcAmplitude, +} from "../../animation/utils/arc" import { getValueTransition } from "../../animation/utils/get-value-transition" import { cancelFrame, frame, frameData, frameSteps } from "../../frameloop" import { microtask } from "../../frameloop/microtask" @@ -1545,40 +1550,6 @@ export function createProjectionNode({ this.projectionDeltaWithTransform = createDelta() } - computeControlPoints( - originX: number, - originY: number, - targetX: number, - targetY: number, - amplitude: number, - peak: number - ) { - const deltaX = targetX - originX - const deltaY = targetY - originY - - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) - - if (distance > 0) { - const perpX = -deltaY - const perpY = deltaX - - const normalPerpX = perpX / distance - const normalPerpY = perpY / distance - - const midX = originX + deltaX * peak - const midY = originY + deltaY * peak - - const desiredHeight = amplitude * distance - - return { - x: midX + normalPerpX * desiredHeight, - y: midY + normalPerpY * desiredHeight, - } - } else { - return { x: originX, y: originY } - } - } - /** * Animation */ @@ -1658,23 +1629,16 @@ export function createProjectionNode({ if (isInterrupted && this.prevArcAmplitude !== undefined) { amplitude = this.prevArcAmplitude } else { - amplitude = layoutArc.amplitude - const { direction } = layoutArc - if (direction === "cw") { - amplitude *= -1 - } else if (!direction) { - const dominantDelta = - Math.abs(delta.x.translate) >= - Math.abs(delta.y.translate) - ? delta.x.translate - : delta.y.translate - if (dominantDelta < 0) amplitude *= -1 - } + amplitude = resolveArcAmplitude( + layoutArc, + delta.x.translate, + delta.y.translate + ) } this.prevArcAmplitude = amplitude - arcControlDelta = this.computeControlPoints( + arcControlDelta = computeArcControlPoint( delta.x.translate, delta.y.translate, 0, @@ -2424,19 +2388,6 @@ function removeLeadSnapshots(stack: NodeStack) { stack.removeLeadSnapshot() } -function bezierPoint( - t: number, - origin: number, - control: number, - target: number -) { - return ( - Math.pow(1 - t, 2) * origin + - 2 * (1 - t) * t * control + - Math.pow(t, 2) * target - ) -} - function mixAxisDeltaLinear(output: AxisDelta, delta: AxisDelta, p: number) { output.translate = mixNumber(delta.translate, 0, p) output.scale = mixNumber(delta.scale, 1, p) From 692ac4d51fe554b9f6077ce54f8214ef00acc5a0 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:13:44 +1100 Subject: [PATCH 10/20] examples --- dev/next/app/layout-curve/page.tsx | 396 ++++++++++++++++------------- 1 file changed, 219 insertions(+), 177 deletions(-) diff --git a/dev/next/app/layout-curve/page.tsx b/dev/next/app/layout-curve/page.tsx index cfee75b1d7..f9bbddf2e8 100644 --- a/dev/next/app/layout-curve/page.tsx +++ b/dev/next/app/layout-curve/page.tsx @@ -1,15 +1,8 @@ "use client" -import { - Arc, - LayoutGroup, - motion, - TargetAndTransition, - Transition, -} from "motion/react" -import { useState } from "react" +import { Arc, LayoutGroup, motion, useAnimate } from "motion/react" +import { useEffect, useState } from "react" export default function Page() { - const [state, setState] = useState("a") const [arc, setArc] = useState({ amplitude: 1 }) return ( @@ -17,8 +10,6 @@ export default function Page() { style={{ display: "grid", gridTemplateColumns: "320px 1fr", - justifyContent: "center", - alignItems: "center", height: "100svh", }} > @@ -30,6 +21,7 @@ export default function Page() { background: "#ffffffcc", borderRight: "solid 1px #00000020", padding: 24, + gap: 8, }} > {`{`arc: { + amplitude: ${arc.amplitude},${ + arc.peak !== undefined ? `\n peak: ${arc.peak},` : "" }${ arc.direction !== undefined - ? `\n direction: "${arc.direction}",` + ? `\n direction: "${arc.direction}",` : "" } - }, - }, - }} -/>`} - +}`} + - setArc({ - ...arc, - amplitude: Number(e.target.value), - }) + setArc({ ...arc, amplitude: Number(e.target.value) }) } /> - + - setArc({ - ...arc, - peak: Number(e.target.value), - }) + setArc({ ...arc, peak: Number(e.target.value) }) } /> - + -
-
- +
+
) } -const Example = ({ +const Section = ({ + title, children, - variant, }: { + title: string children: React.ReactNode - variant?: string -}) => { - return ( +}) => ( +
- {children} + {title}
- ) -} + {children} +
+) -const Examples = ({ state, arc }: { state: string; arc: Arc }) => { +const Examples = ({ arc }: { arc: Arc }) => { return ( -
- +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
- - - - +
- - - - - - +
+ +
+
+ +
+ +
+ +
+
+ ) +} + +function LayoutExample({ + id, + arc, + layout, +}: { + id: string + arc: Arc + layout: "horizontal" | "vertical" | "diagonal" +}) { + const [active, setActive] = useState("a") + + const containerStyle = + layout === "horizontal" + ? { display: "flex", gap: 8 } + : layout === "vertical" + ? { + display: "flex", + flexDirection: "column" as const, + gap: 8, + alignItems: "flex-start" as const, + } + : { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: 8, + maxWidth: 400, + } + + return ( +
+ +
- - - - - -
-
+ {layout === "diagonal" &&
} + {layout === "diagonal" &&
} - - +
+ +
) } @@ -274,7 +250,6 @@ function NavigationItem({ > {isActive && ( {title} @@ -302,47 +276,115 @@ function NavigationItem({ ) } +function UseAnimateExample({ arc }: { arc: Arc }) { + const [scope, animate] = useAnimate() + const [toggled, setToggled] = useState(false) + + useEffect(() => { + animate( + scope.current, + { x: toggled ? 168 : 0, y: toggled ? 168 : 0 }, + { duration: 1, ease: "easeInOut", arc } + ) + }, [toggled, arc, animate, scope]) + + return ( +
+
+
+
+ +
+ ) +} + const MotionExample = ({ animate, - transition, + baseTransition, arc, }: { - animate: TargetAndTransition - transition: Transition + animate: { x?: number[]; y?: number[]; scale?: number[] } + baseTransition: { duration: number; ease: "easeInOut" } arc: Arc }) => { return (
- - + > + + +
) } From 83260efde1911f487610b005b682e985055e5bdd Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:14:38 +1100 Subject: [PATCH 11/20] move examples --- dev/next/app/{layout-curve => arcs}/page.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dev/next/app/{layout-curve => arcs}/page.tsx (100%) diff --git a/dev/next/app/layout-curve/page.tsx b/dev/next/app/arcs/page.tsx similarity index 100% rename from dev/next/app/layout-curve/page.tsx rename to dev/next/app/arcs/page.tsx From 0382b9299fed01dd56355f847ec1ce68a82805fd Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:24:18 +1100 Subject: [PATCH 12/20] outdated comments --- packages/motion-dom/src/animation/types.ts | 5 +++-- .../src/projection/node/create-projection-node.ts | 7 ------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index 10b59fc38e..502d2bc590 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -481,11 +481,12 @@ export interface ValueTransition inherit?: boolean /** - * Configures an arc path for layout animations. The element will travel + * 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. * - * Only applies when used inside `transition.layout`. + * Can be used in keyframe animations (`transition.arc`) and layout + * animations (`transition.layout.arc`), including with `useAnimate`. * * @public */ diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index de1b894080..c135b2209a 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -1592,11 +1592,6 @@ export function createProjectionNode({ !this.path.some(hasOpacityCrossfade) ) - /** - * If we were mid-animation, this is an interruption. Unless the - * arc is configured with interrupt: "arc", fall back to linear - * so the element takes a direct path to its new target. - */ const isInterrupted = this.animationProgress > 0 this.animationProgress = 0 @@ -1669,8 +1664,6 @@ export function createProjectionNode({ mixAxisDeltaLinear(targetDelta.y, delta.y, progress) } - // targetDelta now = interpolated - this.setTargetDelta(targetDelta) if ( From e3ffae24bc138c95e4474998d8b06da531b415fa Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:26:47 +1100 Subject: [PATCH 13/20] tests --- dev/react/src/tests/layout-arc.tsx | 69 +++++++++++ .../cypress/integration/layout-arc.ts | 76 ++++++++++++ .../src/animation/utils/__tests__/arc.test.ts | 117 ++++++++++++++++++ .../src/projection/node/__tests__/arc.test.ts | 60 +++++++++ 4 files changed, 322 insertions(+) create mode 100644 dev/react/src/tests/layout-arc.tsx create mode 100644 packages/framer-motion/cypress/integration/layout-arc.ts create mode 100644 packages/motion-dom/src/animation/utils/__tests__/arc.test.ts create mode 100644 packages/motion-dom/src/projection/node/__tests__/arc.test.ts diff --git a/dev/react/src/tests/layout-arc.tsx b/dev/react/src/tests/layout-arc.tsx new file mode 100644 index 0000000000..0af8f73694 --- /dev/null +++ b/dev/react/src/tests/layout-arc.tsx @@ -0,0 +1,69 @@ +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 + + const arcConfig = + variant === "none" + ? undefined + : { amplitude: 1 } + + const layoutTransition = arcConfig + ? { layout: { arc: arcConfig, duration: 4, ease: () => 0.5 } } + : { duration: 4, ease: () => 0.5 } + + return ( +
+ + +
+ {active === "a" && ( + + )} +
+
+ {active === "b" && ( + + )} +
+
+
+ ) +} diff --git a/packages/framer-motion/cypress/integration/layout-arc.ts b/packages/framer-motion/cypress/integration/layout-arc.ts new file mode 100644 index 0000000000..b621433c75 --- /dev/null +++ b/packages/framer-motion/cypress/integration/layout-arc.ts @@ -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=layout-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=layout-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=layout-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) + }) + }) +}) diff --git a/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts b/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts new file mode 100644 index 0000000000..da6996c424 --- /dev/null +++ b/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts @@ -0,0 +1,117 @@ +import { + bezierPoint, + computeArcControlPoint, + resolveArcAmplitude, +} from "../arc" + +describe("bezierPoint", () => { + test("at t=0 returns origin", () => { + expect(bezierPoint(0, -200, 0, 200)).toBeCloseTo(-200) + }) + + test("at t=1 returns target", () => { + expect(bezierPoint(1, -200, 0, 200)).toBeCloseTo(200) + }) + + test("at t=0.5 with control at midpoint matches linear midpoint", () => { + // bezierPoint(0.5, -200, 0, 200): control=0 is the midpoint of -200…200 + // = 0.25*-200 + 0.5*0 + 0.25*200 = -50 + 0 + 50 = 0 + expect(bezierPoint(0.5, -200, 0, 200)).toBeCloseTo(0) + }) + + test("off-axis control produces arc deviation at midpoint", () => { + // delta.y = 0 (no movement), control displaced to 400 + // bezierPoint(0.5, 0, 400, 0) = 0 + 0.5*400 + 0 = 200 + expect(bezierPoint(0.5, 0, 400, 0)).toBeCloseTo(200) + }) + + test("returns exact endpoints regardless of control value", () => { + const control = 999 + expect(bezierPoint(0, 10, control, 90)).toBeCloseTo(10) + expect(bezierPoint(1, 10, control, 90)).toBeCloseTo(90) + }) +}) + +describe("computeArcControlPoint", () => { + test("horizontal movement: control perpendicular (downward for amplitude=1)", () => { + // from=(0,0) to=(100,0): perpendicular is downward (+y) + // mid=(50,0), desiredHeight=100, control=(50, 100) + const cp = computeArcControlPoint(0, 0, 100, 0, 1, 0.5) + expect(cp.x).toBeCloseTo(50) + expect(cp.y).toBeCloseTo(100) + }) + + test("negative amplitude flips perpendicular direction (upward)", () => { + const cp = computeArcControlPoint(0, 0, 100, 0, -1, 0.5) + expect(cp.x).toBeCloseTo(50) + expect(cp.y).toBeCloseTo(-100) + }) + + test("vertical movement: control perpendicular (leftward for amplitude=1)", () => { + // from=(0,0) to=(0,100): perpendicular(-deltaY, deltaX) = (-1, 0) = leftward + // mid=(0,50), desiredHeight=100, control=(-100, 50) + const cp = computeArcControlPoint(0, 0, 0, 100, 1, 0.5) + expect(cp.x).toBeCloseTo(-100) + expect(cp.y).toBeCloseTo(50) + }) + + test("asymmetric peak shifts control point along the path", () => { + // peak=0.2 means control point is 20% along path, not 50% + const cpEarly = computeArcControlPoint(0, 0, 100, 0, 1, 0.2) + const cpDefault = computeArcControlPoint(0, 0, 100, 0, 1, 0.5) + expect(cpEarly.x).toBeCloseTo(20) + expect(cpDefault.x).toBeCloseTo(50) + // perpendicular component is the same + expect(cpEarly.y).toBeCloseTo(100) + expect(cpDefault.y).toBeCloseTo(100) + }) + + test("zero distance returns the from point", () => { + const cp = computeArcControlPoint(5, 10, 5, 10, 1, 0.5) + expect(cp.x).toBe(5) + expect(cp.y).toBe(10) + }) + + test("diagonal movement produces correct control point", () => { + // from=(0,0) to=(100,100): distance=√2*100≈141.4 + // perpendicular to (100,100) is (-100,100), normalized: (-1/√2, 1/√2) + // mid=(50,50), desiredHeight=1*141.4≈141.4 + // control=(50 + (-1/√2)*141.4, 50 + (1/√2)*141.4) = (50-100, 50+100) = (-50, 150) + const cp = computeArcControlPoint(0, 0, 100, 100, 1, 0.5) + expect(cp.x).toBeCloseTo(-50, 0) + expect(cp.y).toBeCloseTo(150, 0) + }) +}) + +describe("resolveArcAmplitude", () => { + test("direction='ccw' keeps amplitude positive", () => { + expect(resolveArcAmplitude({ amplitude: 1, direction: "ccw" }, 100, 0)).toBe(1) + expect(resolveArcAmplitude({ amplitude: 1, direction: "ccw" }, -100, 0)).toBe(1) + }) + + test("direction='cw' negates amplitude", () => { + expect(resolveArcAmplitude({ amplitude: 1, direction: "cw" }, 100, 0)).toBe(-1) + expect(resolveArcAmplitude({ amplitude: 0.5, direction: "cw" }, -100, 0)).toBeCloseTo(-0.5) + }) + + test("auto: positive dominant x delta keeps amplitude", () => { + // Moving right: deltaX=400, auto → dominantDelta=400 > 0 → no flip + expect(resolveArcAmplitude({ amplitude: 1 }, 400, 0)).toBe(1) + }) + + test("auto: negative dominant x delta negates amplitude", () => { + // Moving left: deltaX=-400, auto → dominantDelta=-400 < 0 → flip + expect(resolveArcAmplitude({ amplitude: 1 }, -400, 0)).toBe(-1) + }) + + test("auto: y dominant when |y| > |x|", () => { + // deltaY=-300 is dominant over deltaX=100 + expect(resolveArcAmplitude({ amplitude: 1 }, 100, -300)).toBe(-1) + expect(resolveArcAmplitude({ amplitude: 1 }, 100, 300)).toBe(1) + }) + + test("respects custom amplitude magnitude", () => { + expect(resolveArcAmplitude({ amplitude: 0.7 }, 100, 0)).toBeCloseTo(0.7) + expect(resolveArcAmplitude({ amplitude: 0.7 }, -100, 0)).toBeCloseTo(-0.7) + }) +}) diff --git a/packages/motion-dom/src/projection/node/__tests__/arc.test.ts b/packages/motion-dom/src/projection/node/__tests__/arc.test.ts new file mode 100644 index 0000000000..3f58cdd567 --- /dev/null +++ b/packages/motion-dom/src/projection/node/__tests__/arc.test.ts @@ -0,0 +1,60 @@ +import { mixAxisDelta } from "../create-projection-node" +import type { AxisDelta } from "motion-utils" + +function makeAxisDelta( + translate: number, + scale = 1, + origin = 0, + originPoint = 0 +): AxisDelta { + return { translate, scale, origin, originPoint } +} + +describe("mixAxisDelta", () => { + test("at progress 0 returns origin translate", () => { + const output = makeAxisDelta(0) + mixAxisDelta(output, makeAxisDelta(-300), -150, 0) + expect(output.translate).toBeCloseTo(-300) + expect(output.scale).toBeCloseTo(1) + }) + + test("at progress 1 returns target translate (0) and scale (1)", () => { + const output = makeAxisDelta(0) + mixAxisDelta(output, makeAxisDelta(-300, 0.5), -150, 1) + expect(output.translate).toBeCloseTo(0) + expect(output.scale).toBeCloseTo(1) + }) + + test("at progress 0.5 with on-axis control, matches quadratic Bezier midpoint", () => { + const output = makeAxisDelta(0) + // bezierPoint(0.5, -300, -150, 0) + // = 0.25 * -300 + 0.5 * -150 + 0 = -75 + -75 = -150 + mixAxisDelta(output, makeAxisDelta(-300), -150, 0.5) + expect(output.translate).toBeCloseTo(-150) + }) + + test("off-axis control creates perpendicular displacement at midpoint", () => { + const output = makeAxisDelta(0) + // delta.translate=0 (no movement on this axis), control=-300 creates arc + // bezierPoint(0.5, 0, -300, 0) = 0 + 0.5 * -300 + 0 = -150 + mixAxisDelta(output, makeAxisDelta(0), -300, 0.5) + expect(output.translate).toBeCloseTo(-150) + }) + + test("off-axis control gives zero deviation at endpoints", () => { + const output = makeAxisDelta(0) + mixAxisDelta(output, makeAxisDelta(0), -300, 0) + expect(output.translate).toBeCloseTo(0) + + mixAxisDelta(output, makeAxisDelta(0), -300, 1) + expect(output.translate).toBeCloseTo(0) + }) + + test("preserves origin and originPoint from delta", () => { + const delta = makeAxisDelta(-300, 1, 0.5, 150) + const output = makeAxisDelta(0) + mixAxisDelta(output, delta, 0, 0.5) + expect(output.origin).toBe(0.5) + expect(output.originPoint).toBe(150) + }) +}) From 654b8ab3de5a62a9214b40fac8ede35df6a7962a Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:27:42 +1100 Subject: [PATCH 14/20] redundant alias --- .../src/projection/node/create-projection-node.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index c135b2209a..64533ada7e 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -1603,9 +1603,8 @@ export function createProjectionNode({ * Skip if the distance is below the minimum threshold to avoid * a visible wobble on very small layout shifts. */ - const layoutArc = arc const shouldArc = - layoutArc && + arc && Math.sqrt( delta.x.translate * delta.x.translate + delta.y.translate * delta.y.translate @@ -1613,7 +1612,7 @@ export function createProjectionNode({ let arcControlDelta: { x: number; y: number } | undefined - if (shouldArc && layoutArc) { + if (shouldArc && arc) { let amplitude: number /** @@ -1625,7 +1624,7 @@ export function createProjectionNode({ amplitude = this.prevArcAmplitude } else { amplitude = resolveArcAmplitude( - layoutArc, + arc, delta.x.translate, delta.y.translate ) @@ -1639,7 +1638,7 @@ export function createProjectionNode({ 0, 0, amplitude, - layoutArc.peak ?? 0.5 + arc.peak ?? 0.5 ) } From ddbce8194978d82a94901533658348b976a4bfc9 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:29:59 +1100 Subject: [PATCH 15/20] export Arc type --- packages/framer-motion/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/framer-motion/src/index.ts b/packages/framer-motion/src/index.ts index 8cba96369d..56e861a4b0 100644 --- a/packages/framer-motion/src/index.ts +++ b/packages/framer-motion/src/index.ts @@ -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 { From 86f77ad9b82fa76796016617d4cb431fd0e84e5d Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 04:54:17 +1100 Subject: [PATCH 16/20] fix test --- .../{layout-arc.tsx => transition-arc.tsx} | 19 ++++++++++--------- .../{layout-arc.ts => transition-arc.ts} | 6 +++--- 2 files changed, 13 insertions(+), 12 deletions(-) rename dev/react/src/tests/{layout-arc.tsx => transition-arc.tsx} (81%) rename packages/framer-motion/cypress/integration/{layout-arc.ts => transition-arc.ts} (94%) diff --git a/dev/react/src/tests/layout-arc.tsx b/dev/react/src/tests/transition-arc.tsx similarity index 81% rename from dev/react/src/tests/layout-arc.tsx rename to dev/react/src/tests/transition-arc.tsx index 0af8f73694..76a042eb67 100644 --- a/dev/react/src/tests/layout-arc.tsx +++ b/dev/react/src/tests/transition-arc.tsx @@ -13,14 +13,15 @@ export const App = () => { const isSmall = variant === "small" const itemB = isSmall ? ITEM_B_NEAR : ITEM_B - const arcConfig = + /** + * 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" - ? undefined - : { amplitude: 1 } - - const layoutTransition = arcConfig - ? { layout: { arc: arcConfig, duration: 4, ease: () => 0.5 } } - : { duration: 4, ease: () => 0.5 } + ? { duration: 4, ease: () => 0.5 } + : { duration: 4, ease: () => 0.5, arc: { amplitude: 1 } } return (
{ { { it("deviates from the straight-line path mid-animation", () => { - cy.visit("?test=layout-arc") + cy.visit("?test=transition-arc") .wait(50) // Confirm starting position .get("#indicator") @@ -37,7 +37,7 @@ describe("layout arc", () => { }) it("stays on the straight-line path without arc config", () => { - cy.visit("?test=layout-arc&variant=none") + cy.visit("?test=transition-arc&variant=none") .wait(50) .get("#indicator") .should(([$el]: any) => { @@ -56,7 +56,7 @@ describe("layout arc", () => { }) it("does not arc for movements below the 20px minimum distance", () => { - cy.visit("?test=layout-arc&variant=small") + cy.visit("?test=transition-arc&variant=small") .wait(50) .get("#indicator") .should(([$el]: any) => { From 3cadefe412af87fb8f5bf730bbeb0836bfb7e5d6 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 05:40:30 +1100 Subject: [PATCH 17/20] no should --- .../cypress/integration/transition-arc.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/framer-motion/cypress/integration/transition-arc.ts b/packages/framer-motion/cypress/integration/transition-arc.ts index 9c2d3694c2..0ac09481f1 100644 --- a/packages/framer-motion/cypress/integration/transition-arc.ts +++ b/packages/framer-motion/cypress/integration/transition-arc.ts @@ -20,7 +20,7 @@ describe("layout arc", () => { .wait(50) // Confirm starting position .get("#indicator") - .should(([$el]: any) => { + .then(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 10) }) @@ -30,7 +30,7 @@ describe("layout arc", () => { .wait(100) // At 50% progress the arc displaces the element ~200px from baseline .get("#indicator") - .should(([$el]: any) => { + .then(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(Math.abs(top - 200)).to.be.greaterThan(80) }) @@ -40,7 +40,7 @@ describe("layout arc", () => { cy.visit("?test=transition-arc&variant=none") .wait(50) .get("#indicator") - .should(([$el]: any) => { + .then(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 10) }) @@ -49,7 +49,7 @@ describe("layout arc", () => { .wait(100) // No arc — y stays at ≈200 throughout .get("#indicator") - .should(([$el]: any) => { + .then(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 20) }) @@ -59,7 +59,7 @@ describe("layout arc", () => { cy.visit("?test=transition-arc&variant=small") .wait(50) .get("#indicator") - .should(([$el]: any) => { + .then(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 10) }) @@ -68,7 +68,7 @@ describe("layout arc", () => { .wait(100) // 10px movement — below threshold, stays linear .get("#indicator") - .should(([$el]: any) => { + .then(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 20) }) From b2b822af647c249a066fd1651b123f7a998d4143 Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 06:10:37 +1100 Subject: [PATCH 18/20] Revert "no should" This reverts commit 3cadefe412af87fb8f5bf730bbeb0836bfb7e5d6. --- .../cypress/integration/transition-arc.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/framer-motion/cypress/integration/transition-arc.ts b/packages/framer-motion/cypress/integration/transition-arc.ts index 0ac09481f1..9c2d3694c2 100644 --- a/packages/framer-motion/cypress/integration/transition-arc.ts +++ b/packages/framer-motion/cypress/integration/transition-arc.ts @@ -20,7 +20,7 @@ describe("layout arc", () => { .wait(50) // Confirm starting position .get("#indicator") - .then(([$el]: any) => { + .should(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 10) }) @@ -30,7 +30,7 @@ describe("layout arc", () => { .wait(100) // At 50% progress the arc displaces the element ~200px from baseline .get("#indicator") - .then(([$el]: any) => { + .should(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(Math.abs(top - 200)).to.be.greaterThan(80) }) @@ -40,7 +40,7 @@ describe("layout arc", () => { cy.visit("?test=transition-arc&variant=none") .wait(50) .get("#indicator") - .then(([$el]: any) => { + .should(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 10) }) @@ -49,7 +49,7 @@ describe("layout arc", () => { .wait(100) // No arc — y stays at ≈200 throughout .get("#indicator") - .then(([$el]: any) => { + .should(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 20) }) @@ -59,7 +59,7 @@ describe("layout arc", () => { cy.visit("?test=transition-arc&variant=small") .wait(50) .get("#indicator") - .then(([$el]: any) => { + .should(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 10) }) @@ -68,7 +68,7 @@ describe("layout arc", () => { .wait(100) // 10px movement — below threshold, stays linear .get("#indicator") - .then(([$el]: any) => { + .should(([$el]: any) => { const { top } = $el.getBoundingClientRect() expect(top).to.be.closeTo(200, 20) }) From 2e8242a1a4d12b349e664e78797ee0541ca4359a Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Fri, 13 Mar 2026 15:29:03 +1100 Subject: [PATCH 19/20] fix: test --- dev/react/src/tests/transition-arc.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dev/react/src/tests/transition-arc.tsx b/dev/react/src/tests/transition-arc.tsx index 76a042eb67..300462eced 100644 --- a/dev/react/src/tests/transition-arc.tsx +++ b/dev/react/src/tests/transition-arc.tsx @@ -43,9 +43,9 @@ export const App = () => { layoutId="indicator" transition={transition} style={{ - position: "absolute", - inset: 0, - background: "blue", + width: 100, + height: 100, + background: "red", }} /> )} @@ -57,9 +57,9 @@ export const App = () => { layoutId="indicator" transition={transition} style={{ - position: "absolute", - inset: 0, - background: "blue", + width: 100, + height: 100, + background: "red", }} /> )} From 0d4961177b5afde159795514d2cdd816de262fce Mon Sep 17 00:00:00 2001 From: Lochie Axon Date: Sat, 14 Mar 2026 21:33:01 +1100 Subject: [PATCH 20/20] add `orientToPath` --- dev/next/app/arcs/page.tsx | 23 ++++++++- .../interfaces/visual-element-target.ts | 44 +++++++++++++++++ packages/motion-dom/src/animation/types.ts | 8 ++++ .../src/animation/utils/__tests__/arc.test.ts | 31 ++++++++++++ .../motion-dom/src/animation/utils/arc.ts | 24 ++++++++++ .../projection/node/create-projection-node.ts | 47 +++++++++++++++++++ 6 files changed, 176 insertions(+), 1 deletion(-) diff --git a/dev/next/app/arcs/page.tsx b/dev/next/app/arcs/page.tsx index f9bbddf2e8..ee1d5d718f 100644 --- a/dev/next/app/arcs/page.tsx +++ b/dev/next/app/arcs/page.tsx @@ -40,7 +40,7 @@ export default function Page() { arc.direction !== undefined ? `\n direction: "${arc.direction}",` : "" - } + }${arc.orientToPath ? `\n orientToPath: ${arc.orientToPath === true ? "true" : arc.orientToPath},` : ""} }`} cw + + + setArc({ + ...arc, + orientToPath: + Number(e.target.value) || undefined, + }) + } + />
diff --git a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts index 6c50559aba..49a6b1932d 100644 --- a/packages/motion-dom/src/animation/interfaces/visual-element-target.ts +++ b/packages/motion-dom/src/animation/interfaces/visual-element-target.ts @@ -8,7 +8,9 @@ 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" @@ -100,6 +102,31 @@ export function animateTarget( 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"), @@ -116,10 +143,26 @@ export function animateTarget( 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) }, }) ) @@ -128,6 +171,7 @@ export function animateTarget( delete (target as any).x delete (target as any).y + if (arc.orientToPath) delete (target as any).rotate } for (const key in target) { diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index 502d2bc590..998270cdec 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -635,6 +635,14 @@ export interface Arc { * 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 = (i: number, total: number) => T diff --git a/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts b/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts index da6996c424..0561835d4f 100644 --- a/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts +++ b/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts @@ -1,5 +1,6 @@ import { bezierPoint, + bezierTangentAngle, computeArcControlPoint, resolveArcAmplitude, } from "../arc" @@ -83,6 +84,36 @@ describe("computeArcControlPoint", () => { }) }) +describe("bezierTangentAngle", () => { + test("horizontal line returns 0°", () => { + // from=(0,0) control=(50,0) to=(100,0) — straight horizontal + expect(bezierTangentAngle(0.5, 0, 50, 100, 0, 0, 0)).toBeCloseTo(0) + }) + + test("vertical line returns 90°", () => { + // from=(0,0) control=(0,50) to=(0,100) — straight vertical + expect(bezierTangentAngle(0.5, 0, 0, 0, 0, 50, 100)).toBeCloseTo(90) + }) + + test("t=0 with arc reflects initial tangent direction", () => { + // Horizontal path with downward arc: from=(0,0) control=(50,100) to=(100,0) + // At t=0: dx=2*(control.x-origin.x)=100, dy=2*(control.y-origin.y)=200 + // angle = atan2(200,100) ≈ 63.43° + const angle = bezierTangentAngle(0, 0, 50, 100, 0, 100, 0) + expect(angle).toBeCloseTo(63.43, 0) + }) + + test("t=0.5 with symmetric arc is parallel to chord", () => { + // Symmetric arc: from=(0,0) control=(50,100) to=(100,0) + // At t=0.5: dx=(50-0)+(100-50)=100, dy=(100-0)+(0-100)=0 → 0° + expect(bezierTangentAngle(0.5, 0, 50, 100, 0, 100, 0)).toBeCloseTo(0) + }) + + test("zero-length path returns 0°", () => { + expect(bezierTangentAngle(0.5, 5, 5, 5, 10, 10, 10)).toBeCloseTo(0) + }) +}) + describe("resolveArcAmplitude", () => { test("direction='ccw' keeps amplitude positive", () => { expect(resolveArcAmplitude({ amplitude: 1, direction: "ccw" }, 100, 0)).toBe(1) diff --git a/packages/motion-dom/src/animation/utils/arc.ts b/packages/motion-dom/src/animation/utils/arc.ts index b0981633db..a5c6fc29c9 100644 --- a/packages/motion-dom/src/animation/utils/arc.ts +++ b/packages/motion-dom/src/animation/utils/arc.ts @@ -13,6 +13,30 @@ export function bezierPoint( ) } +export function bezierTangentAngle( + t: number, + originX: number, + controlX: number, + targetX: number, + originY: number, + controlY: number, + targetY: number +): number { + const dx = + 2 * (1 - t) * (controlX - originX) + 2 * t * (targetX - controlX) + const dy = + 2 * (1 - t) * (controlY - originY) + 2 * t * (targetY - controlY) + return Math.atan2(dy, dx) * (180 / Math.PI) +} + +/** + * Wraps an angle difference into the [-180, 180] range to prevent + * flips when the tangent crosses the ±180° atan2 boundary. + */ +export function normalizeAngle(angle: number): number { + return ((((angle + 180) % 360) + 360) % 360) - 180 +} + export function computeArcControlPoint( fromX: number, fromY: number, diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 64533ada7e..aa8f22fabe 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -14,7 +14,9 @@ import { getOptimisedAppearId } from "../../animation/optimized-appear/get-appea import { Arc, Transition, ValueAnimationOptions } from "../../animation/types" import { bezierPoint, + bezierTangentAngle, computeArcControlPoint, + normalizeAngle, resolveArcAmplitude, } from "../../animation/utils/arc" import { getValueTransition } from "../../animation/utils/get-value-transition" @@ -1642,6 +1644,31 @@ export function createProjectionNode({ ) } + const arcRotationScale = + arc?.orientToPath === true + ? 0.5 + : typeof arc?.orientToPath === "number" + ? arc.orientToPath + : 0 + + // Pre-compute start/end tangent angles for normalized rotation + const arcTangentAt0 = + arcControlDelta && arcRotationScale + ? bezierTangentAngle( + 0, + delta.x.translate, arcControlDelta.x, 0, + delta.y.translate, arcControlDelta.y, 0 + ) + : 0 + const arcTangentAt1 = + arcControlDelta && arcRotationScale + ? bezierTangentAngle( + 1, + delta.x.translate, arcControlDelta.x, 0, + delta.y.translate, arcControlDelta.y, 0 + ) + : 0 + this.mixTargetDelta = (latest: number) => { const progress = latest / 1000 @@ -1712,6 +1739,26 @@ export function createProjectionNode({ ) } + if (arcControlDelta && arcRotationScale) { + if (!this.animationValues) + this.animationValues = mixedValues + const raw = bezierTangentAngle( + progress, + delta.x.translate, + arcControlDelta.x, + 0, + delta.y.translate, + arcControlDelta.y, + 0 + ) + const baseline = + arcTangentAt0 + + normalizeAngle(arcTangentAt1 - arcTangentAt0) * + progress + this.animationValues.rotate = + normalizeAngle(raw - baseline) * arcRotationScale + } + this.root.scheduleUpdateProjection() this.scheduleRender()