diff --git a/dev/next/app/arcs/page.tsx b/dev/next/app/arcs/page.tsx new file mode 100644 index 0000000000..ee1d5d718f --- /dev/null +++ b/dev/next/app/arcs/page.tsx @@ -0,0 +1,411 @@ +"use client" +import { Arc, LayoutGroup, motion, useAnimate } from "motion/react" +import { useEffect, useState } from "react" + +export default function Page() { + const [arc, setArc] = useState({ amplitude: 1 }) + + return ( +
+
+ {`arc: { + amplitude: ${arc.amplitude},${ + arc.peak !== undefined ? `\n peak: ${arc.peak},` : "" + }${ + arc.direction !== undefined + ? `\n direction: "${arc.direction}",` + : "" + }${arc.orientToPath ? `\n orientToPath: ${arc.orientToPath === true ? "true" : arc.orientToPath},` : ""} +}`} + + + setArc({ ...arc, amplitude: Number(e.target.value) }) + } + /> + + + setArc({ ...arc, peak: Number(e.target.value) }) + } + /> + + + + + setArc({ + ...arc, + orientToPath: + Number(e.target.value) || undefined, + }) + } + /> +
+
+ +
+
+ ) +} + +const Section = ({ + title, + children, +}: { + title: string + children: React.ReactNode +}) => ( +
+
+ {title} +
+ {children} +
+) + +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" &&
} + +
+ + +
+ ) +} + +function NavigationItem({ + title, + id, + arc, + isActive, +}: { + title: string + id: string + arc: Arc + isActive?: boolean +}) { + return ( +
+ {isActive && ( + + )} +
+ {title} +
+
+ ) +} + +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, + baseTransition, + arc, +}: { + animate: { x?: number[]; y?: number[]; scale?: number[] } + baseTransition: { duration: number; ease: "easeInOut" } + arc: Arc +}) => { + return ( +
+
+ + +
+
+ ) +} diff --git a/dev/react/src/tests/transition-arc.tsx b/dev/react/src/tests/transition-arc.tsx new file mode 100644 index 0000000000..300462eced --- /dev/null +++ b/dev/react/src/tests/transition-arc.tsx @@ -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 ( +
+ + +
+ {active === "a" && ( + + )} +
+
+ {active === "b" && ( + + )} +
+
+
+ ) +} diff --git a/packages/framer-motion/cypress/integration/transition-arc.ts b/packages/framer-motion/cypress/integration/transition-arc.ts new file mode 100644 index 0000000000..9c2d3694c2 --- /dev/null +++ b/packages/framer-motion/cypress/integration/transition-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=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) + }) + }) +}) 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 { 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 df0c412bca..8d35afa5d8 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,7 @@ export function useVisualElement< */ if (visualElement && isMounted.current) { visualElement.update(props, presenceContext) + } }) 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..49a6b1932d 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,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" @@ -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, diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index 801584dade..998270cdec 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -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 + } /** @@ -595,6 +608,43 @@ export type Transition = | ValueAnimationTransition | TransitionWithValueOverrides +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 = (i: number, total: number) => T export type ValueAnimationWithDynamicDelay = Omit< 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..0561835d4f --- /dev/null +++ b/packages/motion-dom/src/animation/utils/__tests__/arc.test.ts @@ -0,0 +1,148 @@ +import { + bezierPoint, + bezierTangentAngle, + 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("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) + 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/animation/utils/arc.ts b/packages/motion-dom/src/animation/utils/arc.ts new file mode 100644 index 0000000000..a5c6fc29c9 --- /dev/null +++ b/packages/motion-dom/src/animation/utils/arc.ts @@ -0,0 +1,84 @@ +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 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, + 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/node/types.ts b/packages/motion-dom/src/node/types.ts index 0f900e5106..a6d4c409c1 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -72,7 +72,7 @@ export interface Variants { /** * @deprecated */ -export type LegacyAnimationControls = { +export interface LegacyAnimationControls { /** * Subscribes a component's animation controls to this. * @@ -538,7 +538,6 @@ export interface MotionNodeTapHandlers { * Note: This is not supported publically. */ globalTapTarget?: boolean - } /** @@ -968,6 +967,7 @@ export interface MotionNodeLayoutOptions { * to `false`, this element will take its default opacity throughout the animation. */ layoutCrossfade?: boolean + } /** 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) + }) +}) 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 651b490f89..aa8f22fabe 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -11,7 +11,14 @@ import { import { animateSingleValue } from "../../animation/animate/single-value" import { JSAnimation } from "../../animation/JSAnimation" import { getOptimisedAppearId } from "../../animation/optimized-appear/get-appear-id" -import { Transition, ValueAnimationOptions } from "../../animation/types" +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" import { cancelFrame, frame, frameData, frameSteps } from "../../frameloop" import { microtask } from "../../frameloop/microtask" @@ -574,7 +581,8 @@ export function createProjectionNode({ */ this.setAnimationOrigin( delta, - hasOnlyRelativeTargetChanged + hasOnlyRelativeTargetChanged, + animationOptions.arc ) } else { /** @@ -1552,10 +1560,12 @@ export function createProjectionNode({ currentAnimation?: JSAnimation mixTargetDelta: (progress: number) => void animationProgress = 0 + prevArcAmplitude?: number setAnimationOrigin( delta: Delta, - hasOnlyRelativeTargetChanged: boolean = false + hasOnlyRelativeTargetChanged: boolean = false, + arc?: Arc ) { const snapshot = this.snapshot const snapshotLatestValues = snapshot ? snapshot.latestValues : {} @@ -1584,15 +1594,102 @@ export function createProjectionNode({ !this.path.some(hasOpacityCrossfade) ) + const isInterrupted = this.animationProgress > 0 + this.animationProgress = 0 let prevRelativeTarget: Box + /** + * Pre-compute whether the arc should be applied for this animation. + * Skip if the distance is below the minimum threshold to avoid + * a visible wobble on very small layout shifts. + */ + const shouldArc = + arc && + Math.sqrt( + delta.x.translate * delta.x.translate + + delta.y.translate * delta.y.translate + ) >= 20 + + let arcControlDelta: { x: number; y: number } | undefined + + if (shouldArc && arc) { + let amplitude: number + + /** + * When interrupted, reuse the signed amplitude from the + * previous arc so the new arc bulges to the same side — + * producing a U-curve rather than an S-curve on reversal. + */ + if (isInterrupted && this.prevArcAmplitude !== undefined) { + amplitude = this.prevArcAmplitude + } else { + amplitude = resolveArcAmplitude( + arc, + delta.x.translate, + delta.y.translate + ) + } + + this.prevArcAmplitude = amplitude + + arcControlDelta = computeArcControlPoint( + delta.x.translate, + delta.y.translate, + 0, + 0, + amplitude, + arc.peak ?? 0.5 + ) + } + + 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 - mixAxisDelta(targetDelta.x, delta.x, progress) - mixAxisDelta(targetDelta.y, delta.y, progress) + if (arcControlDelta) { + mixAxisDelta( + targetDelta.x, + delta.x, + arcControlDelta.x, + progress + ) + mixAxisDelta( + targetDelta.y, + delta.y, + arcControlDelta.y, + progress + ) + } else { + mixAxisDeltaLinear(targetDelta.x, delta.x, progress) + mixAxisDeltaLinear(targetDelta.y, delta.y, progress) + } + this.setTargetDelta(targetDelta) if ( @@ -1642,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() @@ -1716,6 +1833,7 @@ export function createProjectionNode({ this.resumingFrom = this.currentAnimation = this.animationValues = + this.prevArcAmplitude = undefined this.notifyListeners("animationComplete") @@ -2309,13 +2427,25 @@ function removeLeadSnapshots(stack: NodeStack) { stack.removeLeadSnapshot() } -export function mixAxisDelta(output: AxisDelta, delta: AxisDelta, p: number) { +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, + 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 +} + export function mixAxis(output: Axis, from: Axis, to: Axis, p: number) { output.min = mixNumber(from.min, to.min, p) output.max = mixNumber(from.max, to.max, p) diff --git a/packages/motion-dom/src/projection/node/types.ts b/packages/motion-dom/src/projection/node/types.ts index 85fcb8e552..d8c8e7e320 100644 --- a/packages/motion-dom/src/projection/node/types.ts +++ b/packages/motion-dom/src/projection/node/types.ts @@ -117,7 +117,7 @@ export interface IProjectionNode { isTreeAnimating?: boolean isAnimationBlocked?: boolean isTreeAnimationBlocked: () => boolean - setAnimationOrigin(delta: Delta): void + setAnimationOrigin(delta: Delta, hasOnlyRelativeTargetChanged?: boolean): void startAnimation(transition: ValueTransition): void finishAnimation(): void hasCheckedOptimisedAppear: boolean