Make arcs injectable via transition.path = arc()#3699
Make arcs injectable via transition.path = arc()#3699mattgperry wants to merge 24 commits intomainfrom
transition.path = arc()#3699Conversation
This reverts commit 3cadefe.
#3386 hardcodes arc paths into the animator, so every consumer of motion-dom pays ~440-585 gzip bytes whether or not they use them. This routes the same behavior through a generic Path factory: users opt in via `import { arc }`, non-importers pay only the two `if (path)` branches and the type plumbing (~125-175 bytes). ```ts import { arc } from "motion/react" <motion.div animate={{ x: 200 }} transition={{ path: arc() }} /> ``` Bundle size, gzipped, vs main: motion +176 B (was +585 B with hardcoded arc) dom-animation +126 B (was +436 B) dom-max +133 B (was +543 B) animate +128 B (was +437 B) The Path slot accepts `(from, to) => (t) => PathState` — same shape fits future `bezier()`, `svg()`, etc. Other deltas vs #3386: - arc math primitives are now private; users consume via the factory. - `orientToPath: true` maps to 1.0 (full intensity) instead of 0.5. - `arc()` and `arc({})` both work — `amplitude` defaults to 0.5. - Layout interruption continuity moves from a per-projection-node field to closure state on the arc factory; reuse via module scope or useMemo to keep it alive across renders. - `isInterrupted` parameter dropped from Path — auto-direction handles clean reversals naturally; the closure handles dominant-axis change. - Cypress coverage expanded to keyframe arc + orientToPath rotation. - Dev playground variants: ping-pong, axis-change, interrupt, cw, ccw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryRefactors the
Confidence Score: 3/5Not ready to merge as-is: two rotation-related defects will produce visually wrong output for elements that have a pre-existing rotate style or that combine an explicit rotate target with orientToPath. The layout arc writes point.rotate directly to animationValues.rotate, discarding the element's existing base rotation — an element styled with rotate: 45 will visibly snap to 0° at animation start. The keyframe path handler also silently drops any explicit rotate target when orientToPath is active. The core arc math and layout translate logic are correct and well-tested, but these two rotation paths are untested and affect real usage scenarios. packages/motion-dom/src/projection/node/create-projection-node.ts (layout arc rotation) and packages/motion-dom/src/animation/interfaces/visual-element-target.ts (keyframe rotate target deletion) Important Files Changed
Sequence DiagramsequenceDiagram
participant User as User Code
participant arc as arc() factory
participant VET as animateTarget (keyframe)
participant CPN as setAnimationOrigin (layout)
participant MV as progress motionValue
participant RE as Render Engine
User->>arc: "arc({ amplitude, orientToPath, ... })"
arc-->>User: Path fn closes over prevBulgeSign
User->>VET: transition with path arcFn
VET->>arc: arcFn(from, to) returns PathInterpolator
VET->>MV: motionValue(0) animate 0 to 1000
loop each frame
MV->>arc: interpolate t over 1000
arc-->>MV: point x y rotate
MV->>RE: xValue set, yValue set
end
MV->>RE: onComplete snap to xTo yTo
User->>CPN: transition layout with path arcFn
CPN->>arc: arcFn delta to zero
loop each frame mixTargetDelta
CPN->>arc: interpolate progress
arc-->>CPN: point
CPN->>RE: targetDelta translate set
CPN->>RE: animationValues rotate set
end
Reviews (1): Last reviewed commit: "Make arcs injectable via `transition.pat..." | Re-trigger Greptile |
| if (interpolate) { | ||
| const point = interpolate(progress) | ||
| if (point.rotate !== undefined) { | ||
| if (!this.animationValues) | ||
| this.animationValues = mixedValues | ||
| this.animationValues.rotate = point.rotate | ||
| } | ||
| } |
There was a problem hiding this comment.
The arc rotation is applied as an absolute value, overwriting any existing
rotate on the element. mixedValues is a copy of this.latestValues taken at the top of setAnimationOrigin, so mixedValues.rotate holds the element's base rotation. Writing point.rotate directly replaces it, meaning an element with e.g. style={{ rotate: 45 }} will snap to 0° at the start of the animation and return to 45° only after completion — rather than smoothly adding the arc's delta on top. The keyframe path in visual-element-target.ts correctly does baseRotation + point.rotate; the same pattern is needed here.
| if (interpolate) { | |
| const point = interpolate(progress) | |
| if (point.rotate !== undefined) { | |
| if (!this.animationValues) | |
| this.animationValues = mixedValues | |
| this.animationValues.rotate = point.rotate | |
| } | |
| } | |
| if (interpolate) { | |
| const point = interpolate(progress) | |
| if (point.rotate !== undefined) { | |
| if (!this.animationValues) | |
| this.animationValues = mixedValues | |
| const baseRot = (this.latestValues.rotate as number) ?? 0 | |
| this.animationValues.rotate = baseRot + point.rotate | |
| } | |
| } |
| delete (target as { x?: unknown }).x | ||
| delete (target as { y?: unknown }).y | ||
| if (rotateValue) delete (target as { rotate?: unknown }).rotate |
There was a problem hiding this comment.
When
orientToPath is active, any explicit rotate present in the animate target (e.g. animate={{ x: 200, y: 100, rotate: 45 }}) is deleted from target and never animated. onComplete then resets to baseRotation (the start value), so the intended end-state rotation is silently dropped. If the user combines an explicit rotate target with orientToPath, the rotation will not reach its specified value.
| delete (target as { x?: unknown }).x | |
| delete (target as { y?: unknown }).y | |
| if (rotateValue) delete (target as { rotate?: unknown }).rotate | |
| delete (target as { x?: unknown }).x | |
| delete (target as { y?: unknown }).y | |
| // Only delete rotate from the target when orientToPath owns it. | |
| // If the user also set an explicit rotate target, preserve it so | |
| // the per-key loop can animate it independently after the arc ends. | |
| if (rotateValue && !("rotate" in target)) { | |
| delete (target as { rotate?: unknown }).rotate | |
| } |
|
it's happening 🥹 |
|
yes!! these improvements are awesome 🙌 |
|
Penned this in for next week! |
Summary
Alternative architecture for #3386 (cc @lochie). Refactors the always-on
transition.arcfield into a tree-shakabletransition.path = arc()slot so non-users don't pay for the bezier math. Same feature surface, smaller default bundle, future-compatible withbezier()/svg().This branch is built on top of
lochie/arcs(#3386) — the diff shown here againstmainincludes Lochie's commits. The injectable refactor itself is the single commit on top.Bundle size impact (gzipped, vs main)
arcimport)motiondom-maxanimatedom-animationmUsers who do import
arcpay similar size as #3386. Users who don't pay only the type plumbing and twoif (path)branches.API
The factory closes over its options.
arc()also tracks previous bulge sign in its closure for cross-call continuity (the dominant-axis-change case where auto-direction picks a different screen side). Reuse the factory (module scope,useMemo,useRef) to keep that alive across renders.Other changes vs #3386
arc()math primitives are now private — only the factory is exportedarc()andarc({})both work;amplitudedefaults to0.5orientToPath: truenow maps to1.0(full intensity) instead of0.5isInterruptedparameter dropped fromPath— auto-direction handles clean reversals naturallyorientToPathrotation, no-orient pure translateping-pong,axis-change,interrupt,cw,ccwKnown issue inherited from #3386
lochie/arcsis missing the post-mergelayoutAnchorfrom main (commitbd5de2b3d, PR #3028). The diff against main currently showslayoutAnchoras deleted — this PR's branch inherits that. Fix is to rebase or mergemaininto the source branch before merging.Test plan
packages/motion-dom/src/animation/utils/__tests__/arc.test.ts?test=transition-arc&variant=...)pathname (vsvia/along/etc.) — see description🤖 Generated with Claude Code