Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b4aaba0

Browse files
mattgperryclaude
andcommitted
pathRotation: non-destructive orientToPath rotation channel
Compose arc orientToPath rotation onto the user's rotate via a dedicated pathRotation value instead of clobbering rotate. Includes simplify-pass fixes: route pathRotation through transformProps, single per-frame interpolate() in mixTargetDelta, pathFn-guarded distance, reuse wrap(). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 8604ef3 commit b4aaba0

10 files changed

Lines changed: 175 additions & 53 deletions

File tree

dev/react/src/tests/transition-arc.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const App = () => {
3030

3131
if (variant === "ping-pong") return <PingPong />
3232
if (variant === "axis-change") return <AxisChange />
33+
if (variant === "rotate-compose") return <RotateCompose />
3334

3435
return <LayoutArc variant={variant} />
3536
}
@@ -235,6 +236,52 @@ const AxisChange = () => {
235236
)
236237
}
237238

239+
/**
240+
* An oriented arc running *at the same time* as a user `rotate`
241+
* animation. Frozen at t=0.5: pathRotation is ~0 by symmetry there, so
242+
* the only rotation in the matrix should be the user's `rotate` at 50%
243+
* (0 → 90 → 45deg). If the arc clobbered `rotate` (the old behaviour)
244+
* the element would read ~0deg instead. Proves composition + that the
245+
* user's value is never overwritten.
246+
*/
247+
const RotateCompose = () => {
248+
const [target, setTarget] = useState<"a" | "b">("a")
249+
const path = useRef(arc({ amplitude: 1, orientToPath: true })).current
250+
251+
return (
252+
<div
253+
id="container"
254+
style={{ position: "relative", width: "100vw", height: "100vh" }}
255+
>
256+
<Hud variant="rotate-compose" />
257+
<button
258+
id="toggle"
259+
onClick={() => setTarget((p) => (p === "a" ? "b" : "a"))}
260+
style={{ position: "fixed", top: 16, left: 16 }}
261+
>
262+
Toggle
263+
</button>
264+
<motion.div
265+
id="indicator"
266+
animate={{
267+
x: target === "a" ? 0 : 400,
268+
y: 0,
269+
rotate: target === "a" ? 0 : 90,
270+
}}
271+
transition={{ duration: 4, ease: () => 0.5, path }}
272+
style={{
273+
position: "absolute",
274+
top: 200,
275+
left: 50,
276+
width: 100,
277+
height: 100,
278+
background: "red",
279+
}}
280+
/>
281+
</div>
282+
)
283+
}
284+
238285
const Hud = ({ variant }: { variant: string }) => (
239286
<div
240287
style={{

packages/framer-motion/cypress/integration/transition-arc.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,40 @@ describe("keyframe arc", () => {
141141
})
142142
})
143143
})
144+
145+
/**
146+
* An oriented arc running concurrently with a user `rotate` animation.
147+
* `pathRotation` is composed onto the user's `rotate` at the build site,
148+
* so the user's value must never be read or clobbered.
149+
*
150+
* Frozen at t=0.5: pathRotation ~0 by symmetry, x ~mid, and `rotate`
151+
* (0 → 90, eased 0.5) ~45deg. The old clobbering behaviour would leave
152+
* rotation at ~0deg instead.
153+
*/
154+
describe("arc composes with concurrent rotate", () => {
155+
it("does not clobber a user rotate animation", () => {
156+
cy.visit("?test=transition-arc&variant=rotate-compose")
157+
.wait(50)
158+
.get("#toggle")
159+
.click()
160+
.wait(100)
161+
.get("#indicator")
162+
.should(([$el]: any) => {
163+
const t = window.getComputedStyle($el).transform
164+
const m = t.match(/matrix\(([^)]+)\)/)
165+
if (!m) throw new Error(`expected a matrix, got: ${t}`)
166+
const [a, b, , , tx] = m[1]
167+
.split(",")
168+
.map((v) => parseFloat(v))
169+
// x interpolated to ~mid (0 → 400, eased 0.5).
170+
expect(tx, "x is roughly midway").to.be.closeTo(200, 40)
171+
// Decompose rotation from matrix(a,b,...). Should be ~45deg
172+
// from the user's rotate; clobbering would give ~0deg.
173+
const angle = (Math.atan2(b, a) * 180) / Math.PI
174+
expect(
175+
angle,
176+
"user rotate (45deg) survived alongside the arc"
177+
).to.be.closeTo(45, 12)
178+
})
179+
})
180+
})

packages/motion-dom/src/animation/interfaces/visual-element-target.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -90,22 +90,17 @@ export function animateTarget(
9090
{ x: xFrom, y: yFrom },
9191
{ x: xTo, y: yTo }
9292
)
93-
// Note: the keyframe consumer doesn't need to flag interruption.
94-
// If this animation is replacing an in-flight one, x/y motion
95-
// values already hold the displaced (mid-arc) position, so
96-
// `xFrom`/`yFrom` carry the necessary continuity geometry.
93+
// Interruption needs no flag: x/y already hold the displaced
94+
// mid-arc position, so xFrom/yFrom carry the continuity geometry.
9795

98-
// Probe whether this path produces rotation, and capture/restore base.
96+
// Drive a dedicated `pathRotation` value (composed onto `rotate` at
97+
// the build sites) rather than `rotate` itself, so a concurrent
98+
// rotate animation composes and nothing accumulates on interrupt.
9999
const probe = interpolate(0)
100-
const rotateValue = probe.rotate !== undefined
101-
? visualElement.getValue(
102-
"rotate",
103-
visualElement.latestValues["rotate"] ?? 0
104-
)
105-
: undefined
106-
const baseRotation = rotateValue
107-
? ((rotateValue.get() as number) ?? 0)
108-
: 0
100+
const pathRotationValue =
101+
probe.rotate !== undefined
102+
? visualElement.getValue("pathRotation", 0)
103+
: undefined
109104

110105
const pathTransition = {
111106
delay,
@@ -123,23 +118,26 @@ export function animateTarget(
123118
const point = interpolate(latest / 1000)
124119
xValue?.set(point.x)
125120
yValue?.set(point.y)
126-
if (rotateValue && point.rotate !== undefined) {
127-
rotateValue.set(baseRotation + point.rotate)
121+
if (pathRotationValue && point.rotate !== undefined) {
122+
pathRotationValue.set(point.rotate)
128123
}
129124
},
130125
onComplete: () => {
131126
xValue?.set(xTo)
132127
yValue?.set(yTo)
133-
rotateValue?.set(baseRotation)
128+
pathRotationValue?.set(0)
134129
},
130+
// Interrupt/cancel must clear our additive contribution so
131+
// it can't linger on top of the user's `rotate`.
132+
onStop: () => pathRotationValue?.set(0),
133+
onCancel: () => pathRotationValue?.set(0),
135134
})
136135
)
137136

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

140139
delete (target as { x?: unknown }).x
141140
delete (target as { y?: unknown }).y
142-
if (rotateValue) delete (target as { rotate?: unknown }).rotate
143141
}
144142

145143
for (const key in target) {

packages/motion-dom/src/animation/utils/arc.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { wrap } from "motion-utils"
12
import type { Path, Point2D } from "../types"
23

34
interface ArcOptions {
@@ -32,11 +33,8 @@ function bezierPoint(
3233
control: number,
3334
target: number
3435
): number {
35-
return (
36-
Math.pow(1 - t, 2) * origin +
37-
2 * (1 - t) * t * control +
38-
Math.pow(t, 2) * target
39-
)
36+
const inv = 1 - t
37+
return inv * inv * origin + 2 * inv * t * control + t * t * target
4038
}
4139

4240
function bezierTangentAngle(
@@ -55,10 +53,6 @@ function bezierTangentAngle(
5553
return Math.atan2(dy, dx) * (180 / Math.PI)
5654
}
5755

58-
function normalizeAngle(angle: number): number {
59-
return ((((angle + 180) % 360) + 360) % 360) - 180
60-
}
61-
6256
function computeArcControlPoint(
6357
fromX: number,
6458
fromY: number,
@@ -185,9 +179,8 @@ export function arc({
185179
from.y, control.y, to.y
186180
)
187181
const baseline =
188-
tangent0 + normalizeAngle(tangent1 - tangent0) * t
189-
out.rotate =
190-
normalizeAngle(raw - baseline) * rotationScale
182+
tangent0 + wrap(-180, 180, tangent1 - tangent0) * t
183+
out.rotate = wrap(-180, 180, raw - baseline) * rotationScale
191184
}
192185
return out
193186
}

packages/motion-dom/src/effects/style/transform.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,17 @@ export function buildTransform(state: MotionValueState) {
3737
}
3838
}
3939

40+
// See build-transform.ts: additive `rotate()` so user `rotate` isn't
41+
// clobbered. Not a `transformPropOrder` slot.
42+
const pathRotation = state.latest.pathRotation
43+
if (pathRotation) {
44+
transformIsDefault = false
45+
transform += `rotate(${
46+
typeof pathRotation === "number"
47+
? `${pathRotation}deg`
48+
: pathRotation
49+
}) `
50+
}
51+
4052
return transformIsDefault ? "none" : transform.trim()
4153
}

packages/motion-dom/src/projection/node/create-projection-node.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,23 +1630,25 @@ export function createProjectionNode<I>({
16301630
* `from` is the current translate offset (carries any in-flight
16311631
* displacement when interrupted); `to` is the new layout origin.
16321632
*/
1633-
const distance = Math.sqrt(
1634-
delta.x.translate * delta.x.translate +
1635-
delta.y.translate * delta.y.translate
1636-
)
1637-
const interpolate: PathInterpolator | undefined =
1638-
pathFn && distance >= 20
1639-
? pathFn(
1640-
{ x: delta.x.translate, y: delta.y.translate },
1641-
{ x: 0, y: 0 }
1642-
)
1643-
: undefined
1633+
let interpolate: PathInterpolator | undefined
1634+
if (pathFn) {
1635+
const distance = Math.sqrt(
1636+
delta.x.translate * delta.x.translate +
1637+
delta.y.translate * delta.y.translate
1638+
)
1639+
if (distance >= 20) {
1640+
interpolate = pathFn(
1641+
{ x: delta.x.translate, y: delta.y.translate },
1642+
{ x: 0, y: 0 }
1643+
)
1644+
}
1645+
}
16441646

16451647
this.mixTargetDelta = (latest: number) => {
16461648
const progress = latest / 1000
1649+
const point = interpolate?.(progress)
16471650

1648-
if (interpolate) {
1649-
const point = interpolate(progress)
1651+
if (point) {
16501652
targetDelta.x.translate = point.x
16511653
targetDelta.x.scale = mixNumber(delta.x.scale, 1, progress)
16521654
targetDelta.x.origin = delta.x.origin
@@ -1710,13 +1712,12 @@ export function createProjectionNode<I>({
17101712
)
17111713
}
17121714

1713-
if (interpolate) {
1714-
const point = interpolate(progress)
1715-
if (point.rotate !== undefined) {
1716-
if (!this.animationValues)
1717-
this.animationValues = mixedValues
1718-
this.animationValues.rotate = point.rotate
1719-
}
1715+
if (point && point.rotate !== undefined) {
1716+
// Dedicated `pathRotation` channel, not `rotate`, so an
1717+
// animating `rotate` is composed with, never clobbered.
1718+
if (!this.animationValues)
1719+
this.animationValues = mixedValues
1720+
this.animationValues.pathRotation = point.rotate
17201721
}
17211722

17221723
this.root.scheduleUpdateProjection()

packages/motion-dom/src/projection/styles/transform.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,20 @@ export function buildProjectionTransform(
3030
}
3131

3232
if (latestTransform) {
33-
const { transformPerspective, rotate, rotateX, rotateY, skewX, skewY } =
34-
latestTransform
33+
const {
34+
transformPerspective,
35+
rotate,
36+
pathRotation,
37+
rotateX,
38+
rotateY,
39+
skewX,
40+
skewY,
41+
} = latestTransform
3542
if (transformPerspective)
3643
transform = `perspective(${transformPerspective}px) ${transform}`
3744
if (rotate) transform += `rotate(${rotate}deg) `
45+
// Additive `rotate()` so user `rotate` isn't clobbered.
46+
if (pathRotation) transform += `rotate(${pathRotation}deg) `
3847
if (rotateX) transform += `rotateX(${rotateX}deg) `
3948
if (rotateY) transform += `rotateY(${rotateY}deg) `
4049
if (skewX) transform += `skewX(${skewX}deg) `

packages/motion-dom/src/render/html/utils/build-transform.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ export function buildTransform(
6262
}
6363
}
6464

65+
// `pathRotation` composes onto `rotate` as a separate additive term so
66+
// the user's `rotate` is never clobbered. Deliberately not a slot in
67+
// `transformPropOrder`.
68+
const pathRotation = latestValues.pathRotation
69+
if (pathRotation) {
70+
transformIsDefault = false
71+
transformString += `rotate(${getValueAsType(
72+
pathRotation,
73+
numberValueTypes.pathRotation
74+
)}) `
75+
}
76+
6577
transformString = transformString.trim()
6678

6779
// If we have a custom `transform` template, pass our transform values and

packages/motion-dom/src/render/utils/keys-transform.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export const transformPropOrder = [
2323

2424
/**
2525
* A quick lookup for transform props.
26+
*
27+
* `pathRotation` is a transform for routing purposes (skipped from raw
28+
* style application, wired to the transform composite, flags transform
29+
* dirty) but is intentionally NOT in `transformPropOrder` — it is
30+
* composed onto `rotate` at the build sites, not serialized in its own
31+
* slot, and must stay out of the order-array consumers (parse-transform,
32+
* unit-conversion, keys-position).
2633
*/
2734
export const transformProps = /*@__PURE__*/ (() =>
28-
new Set(transformPropOrder))()
35+
new Set([...transformPropOrder, "pathRotation"]))()

packages/motion-dom/src/value/types/maps/transform.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { ValueTypeMap } from "./types"
44

55
export const transformValueTypes: ValueTypeMap = {
66
rotate: degrees,
7+
/**
8+
* Internal channel for `transition.path` orientToPath. Composed onto
9+
* `rotate` at the transform-build sites so the user's `rotate` is
10+
* never read or overwritten. Not part of `transformPropOrder`.
11+
*/
12+
pathRotation: degrees,
713
rotateX: degrees,
814
rotateY: degrees,
915
rotateZ: degrees,

0 commit comments

Comments
 (0)