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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions dev/next/app/layout-curve/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
position: "relative",
padding: 10,
}}
>
{current && (
<motion.span
id="current-indicator"
layoutId="current-indicator"
layoutCurve={{
amplitude: layoutCurveAmplitude,
}}
transition={{ duration: 1, ease: "easeInOut" }}
style={{
zIndex: -1,
position: "absolute",
inset: 0,
backgroundColor: "#DECADE",
}}
/>
)}
<button
id={id}
style={{
position: "relative",
padding: "1rem",
width: "100%",
}}
onClick={onClick}
>
{title}
</button>
</div>
)
}

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 (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100svh",
}}
>
<div
style={{
position: "fixed",
top: 24,
left: 24,
display: "flex",
flexDirection: "column",
}}
>
<label>
<code>layoutCurveAmplitude: {layoutCurveAmplitude}</code>
</label>
<input
type="range"
min={-1000}
max={1000}
value={layoutCurveAmplitude}
onChange={(e) => setCurveAmplitude(Number(e.target.value))}
/>
</div>
<div
style={{
maxWidth: "64rem",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "18rem",
padding: "8rem",
}}
>
<LayoutGroup id={state}>
<NavigationItem
id="a"
title="Primary Location"
current={state === "a"}
onClick={() => setState("a")}
layoutCurveAmplitude={layoutCurveAmplitude}
/>

<NavigationItem
id="b"
title="Secondary Location"
current={state === "b"}
onClick={() => setState("b")}
layoutCurveAmplitude={layoutCurveAmplitude}
/>
</LayoutGroup>
</div>
</div>
</div>
)
}
2 changes: 2 additions & 0 deletions packages/framer-motion/src/motion/utils/use-visual-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ function createProjectionNode(
layoutScroll,
layoutRoot,
layoutCrossfade,
layoutCurve,
} = props

visualElement.projection = new ProjectionNodeConstructor(
Expand All @@ -197,6 +198,7 @@ function createProjectionNode(
animationType: typeof layout === "string" ? layout : "both",
initialPromotionConfig,
crossfade: layoutCrossfade,
layoutCurve,
layoutScroll,
layoutRoot,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,33 @@ export function createProjectionNode<I>({
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
*/
Expand Down Expand Up @@ -1561,8 +1588,27 @@ export function createProjectionNode<I>({
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 (
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/framer-motion/src/projection/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ export interface ProjectionNodeOptions {
layout?: boolean | string
visualElement?: VisualElement
crossfade?: boolean
layoutCurve?: {
amplitude: number
}
transition?: Transition
initialPromotionConfig?: InitialPromotionConfig
}
Expand Down
11 changes: 11 additions & 0 deletions packages/motion-dom/src/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

/**
Expand Down