Skip to content

Make arcs injectable via transition.path = arc()#3699

Open
mattgperry wants to merge 24 commits intomainfrom
lochie/arcs-injectable
Open

Make arcs injectable via transition.path = arc()#3699
mattgperry wants to merge 24 commits intomainfrom
lochie/arcs-injectable

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

Alternative architecture for #3386 (cc @lochie). Refactors the always-on transition.arc field into a tree-shakable transition.path = arc() slot so non-users don't pay for the bezier math. Same feature surface, smaller default bundle, future-compatible with bezier()/svg().

import { arc } from "motion/react"

<motion.div
  animate={{ x: 200, y: 100 }}
  transition={{ path: arc() }}
/>

<motion.div
  layoutId="indicator"
  transition={{ layout: { path: arc({ amplitude: 1, orientToPath: true }) } }}
/>

This branch is built on top of lochie/arcs (#3386) — the diff shown here against main includes Lochie's commits. The injectable refactor itself is the single commit on top.

Bundle size impact (gzipped, vs main)

Bundle #3386 always-on this PR (no arc import) savings
motion +585 B +176 B -409 B
dom-max +543 B +133 B -410 B
animate +437 B +128 B -309 B
dom-animation +436 B +126 B -310 B
m 0 0 0

Users who do import arc pay similar size as #3386. Users who don't pay only the type plumbing and two if (path) branches.

API

type Path = (from: Point2D, to: Point2D) => PathInterpolator
type PathInterpolator = (t: number) => { x: number; y: number; rotate?: number }

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 exported
  • arc() and arc({}) both work; amplitude defaults to 0.5
  • orientToPath: true now maps to 1.0 (full intensity) instead of 0.5
  • isInterrupted parameter dropped from Path — auto-direction handles clean reversals naturally
  • Cypress coverage expanded: keyframe arc deflection, orientToPath rotation, no-orient pure translate
  • Dev playground variants added: ping-pong, axis-change, interrupt, cw, ccw

Known issue inherited from #3386

lochie/arcs is missing the post-merge layoutAnchor from main (commit bd5de2b3d, PR #3028). The diff against main currently shows layoutAnchor as deleted — this PR's branch inherits that. Fix is to rebase or merge main into the source branch before merging.

Test plan

  • Unit tests: 14 in packages/motion-dom/src/animation/utils/__tests__/arc.test.ts
  • Cypress: 6 layout + keyframe tests, R18 ✓ R19 ✓
  • Full framer-motion suite: 774 passing
  • Visual review of dev playground variants (R18: ?test=transition-arc&variant=...)
  • Decide on path name (vs via/along/etc.) — see description

🤖 Generated with Claude Code

lochie and others added 24 commits September 27, 2025 00:46
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-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 7, 2026

Greptile Summary

Refactors the transition.arc field from #3386 into a tree-shakable transition.path = arc() API so the Bézier math is only bundled when imported, saving ~310–410 B gzipped for users who don't use it. The arc() factory supports orientToPath, direction, peak, and amplitude options, and carries a prevBulgeSign closure for cross-call continuity on dominant-axis changes.

  • arc.ts — new quadratic-Bézier factory with auto-direction, tangent-based rotation, and bulge-sign memory; 14 unit tests validate boundary conditions and continuity.
  • visual-element-target.ts — intercepts x/y keyframe targets and drives them via a dedicated progress motionValue that samples the path interpolator on each frame.
  • create-projection-node.ts — passes pathFn into setAnimationOrigin and applies the arc's translate output to the layout delta, with a separate block for orientToPath rotation on animationValues.

Confidence Score: 3/5

Not 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

Filename Overview
packages/motion-dom/src/projection/node/create-projection-node.ts Layout arc integration passes pathFn into setAnimationOrigin and applies curve interpolation to translate deltas. orientToPath rotation is applied without the element's base rotation, causing incorrect rotation for elements with existing rotate styles.
packages/motion-dom/src/animation/interfaces/visual-element-target.ts Intercepts x/y targets when transition.path is set and drives them via a dedicated progress motionValue. Contains a bug where explicit rotate targets are silently dropped when orientToPath is active.
packages/motion-dom/src/animation/utils/arc.ts New arc factory implementing quadratic Bézier path math, auto-direction, peak shifting, orientToPath rotation, and prevBulgeSign closure for cross-call continuity. Logic appears sound.
packages/motion-dom/src/animation/utils/tests/arc.test.ts 14 unit tests covering boundary conditions, direction modes, peak, orientToPath scaling, continuity, zero-distance, and documented limitations. Good coverage of the arc math.
packages/motion-dom/src/animation/types.ts Adds Path, PathInterpolator, PathState, Point2D types and path?: Path to ValueTransition. Types are clean and well-documented.
packages/framer-motion/src/index.ts Exports arc function and the new Path-related types from motion-dom. Clean addition.
packages/framer-motion/cypress/integration/transition-arc.ts Six Cypress integration tests covering layout arc deviation, linear fallback, small-movement threshold, keyframe deflection, orientToPath rotation, and no-orient pure translate. Solid coverage.
packages/motion-dom/src/projection/node/types.ts Updates IProjectionNode.setAnimationOrigin signature to include optional pathFn parameter. Minor whitespace cleanup included.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "Make arcs injectable via `transition.pat..." | Re-trigger Greptile

Comment on lines +1685 to +1692
if (interpolate) {
const point = interpolate(progress)
if (point.rotate !== undefined) {
if (!this.animationValues)
this.animationValues = mixedValues
this.animationValues.rotate = point.rotate
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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
}
}

Comment on lines +139 to +141
delete (target as { x?: unknown }).x
delete (target as { y?: unknown }).y
if (rotateValue) delete (target as { rotate?: unknown }).rotate
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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
}

@pugson
Copy link
Copy Markdown

pugson commented May 7, 2026

it's happening 🥹

@lochie
Copy link
Copy Markdown
Contributor

lochie commented May 7, 2026

yes!! these improvements are awesome 🙌

@mattgperry
Copy link
Copy Markdown
Collaborator Author

Penned this in for next week!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants