Skip to content

Pin correctParentTransform workaround for Reorder inside a scaled parent (#2750)#3759

Open
mattgperry wants to merge 1 commit into
mainfrom
fix-issue-2750-reorder-scaled-parent
Open

Pin correctParentTransform workaround for Reorder inside a scaled parent (#2750)#3759
mattgperry wants to merge 1 commit into
mainfrom
fix-issue-2750-reorder-scaled-parent

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

The bug

#2750 reports Reorder items "moving around non stop" when the parent is scaled and its transform-origin is changed. Concretely, with a Reorder.Group inside an element that has a raw CSS transform: scale():

  • the dragged item translates faster/slower than the cursor (it runs away from the pointer), and
  • reorder thresholds fire at the wrong positions, so the list appears to move/flicker relative to the pointer.

I reproduced the broken baseline in a browser: with transform: scale(1.5); transform-origin: top left the dragged item moves ~1.5× the pointer distance instead of tracking it.

Cause

This is a structural limitation, not a localised bug. The projection / drag measurement system only sees tracked motion values — raw CSS transforms on ancestors are invisible to hasTransform/treeScale (the same blind spot as #3356). The result is a coordinate-space mismatch:

  • Reorder.Item measures its layout box via getBoundingClientRectscreen (scaled) space.
  • the drag offset (point[axis]) lives in the element's local (unscaled) space.

checkReorder then compares item.layout.max + offset against a neighbour's centre across two different spaces, so thresholds are wrong; and the drag offset itself is added to a local-space motion value, so the item tracks the cursor at 1/scale.

PR #3502 previously tried to patch this inside Reorder by scaling the offset by treeScale, and was closed — treeScale can't see a raw CSS scale either. Making the projection engine aware of computed ancestor transforms is a deeper change that collides with the in-flight projection rewrites (#3748/#3749), so it is deliberately not attempted here.

The supported fix (and what this PR pins)

Since #3132, framer-motion ships a public helper, correctParentTransform(ref), that feeds MotionConfig transformPagePoint the inverse of the parent's computed matrix. Because transformPagePoint is applied to both the pan-session pointer points and the projection viewport measurements, it puts gesture offsets and measured boxes back in the same space — which makes Reorder work again inside a scaled parent:

const ref = useRef(null)
<div ref={ref} style={{ transform: "scale(0.5)", transformOrigin: "top left" }}>
  <MotionConfig transformPagePoint={correctParentTransform(ref)}>
    <Reorder.Group ...>...</Reorder.Group>
  </MotionConfig>
</div>

This PR is test-only — it does not change any projection / drag / Reorder source:

  • dev/react/src/tests/reorder-scaled-parent.tsx — fixture with the broken baseline and a ?corrected=true mode (?scale= / ?corrected= query params, transform-origin: top left).
  • packages/framer-motion/cypress/integration/reorder-scaled-parent.ts — spec proving the corrected mode (1) tracks the cursor 1:1 under scale(0.5) and (2) reorders and settles flush. Pins the workaround so future drag/projection refactors can't silently regress it.

Verification

  • New spec passes on React 18 and React 19 (2/2 each).
  • Existing drag-scaled-parent.ts (2/2) and drag-to-reorder.ts (3/3) still pass on React 18; Reorder unit tests pass (3/3).
  • Build note: yarn build (turbo) is incompatible with the installed turbo in a worktree (pipeline vs tasks); packages were built individually via their tsc + rollup scripts.

Scope / known limitation

The static scaled-parent case is fixed by the workaround and is now regression-tested. The harshest variant in #2750 — a parent whose CSS scale/transform-origin are continuously animated — remains a documented projection limitation (every frame changes the items' screen boxes, which re-triggers layout measurement) and is gated behind the projection-engine rewrites; it isn't given an assertion here as a continuously-animating layout under a known limitation would be a flaky test. The supported pattern for animated parents is to drive scale with a tracked motion value (<motion.div style={{ scale }}>), which the projection system can see.

Fixes #2750
Refs #2449, #3132, #3356

Reorder inside an ancestor with a raw CSS `transform: scale()` (and a
non-centre `transform-origin`) misbehaves: the dragged item translates
faster/slower than the cursor and reorder thresholds fire at the wrong
positions, so the list appears to "move around". Root cause is structural —
the projection / drag measurement system only sees tracked motion values, so
raw CSS transforms on ancestors are invisible (same blind spot as #3356).
Layout boxes are measured in screen (scaled) space while the drag offset
lives in local (unscaled) space.

The supported fix already ships on main: `correctParentTransform(ref)` fed to
`MotionConfig transformPagePoint`, which routes BOTH pan-session pointer
points and projection viewport measurements through the inverse of the
parent's computed matrix, putting gesture offsets and measured boxes back in
the same space.

This is a test-only change that pins that workaround:
- dev/react/src/tests/reorder-scaled-parent.tsx — fixture with a broken
  baseline and a `?corrected=true` mode (`?scale=` / `?corrected=` params).
- reorder-scaled-parent.ts — Cypress spec proving the corrected mode tracks
  the cursor and reorders + settles correctly. Passes on React 18 and 19.

No projection/drag/Reorder source is changed. PR #3502's `treeScale` offset
scaling was rejected because treeScale cannot see raw CSS scale; native engine
awareness of computed ancestor transforms is gated behind the in-flight
projection rewrites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This is a test-only PR that pins the correctParentTransform workaround for Reorder inside a raw CSS scaled parent. No production source is modified — the change consists of a new Cypress fixture (reorder-scaled-parent.tsx) and a two-test spec (reorder-scaled-parent.ts) that guard against future drag/projection refactors silently regressing the behaviour described in #2750.

Confidence Score: 4/5

Safe to merge — no production code is touched and the spec correctly pins the workaround; two minor robustness gaps in the test itself are worth addressing before future CI churn.

The change is test-only and the fixture/spec logic is sound. Two issues in the Cypress spec reduce robustness: the post-release settle assertion silently passes if the computed transform is matrix3d() (framer-motion's projection can emit this form), and the trigger coordinate calculation uses viewport-absolute values as element-relative offsets — a pattern that diverges from every other test in the repo and can cause the incremental drag loop's effective pointer delta to drift as the item moves between events.

packages/framer-motion/cypress/integration/reorder-scaled-parent.ts — the settle check and the trigger coordinate approach both need a second look.

Important Files Changed

Filename Overview
dev/react/src/tests/reorder-scaled-parent.tsx New test fixture exposing the ?corrected=true / ?scale= modes; correctly wires correctParentTransform(ref) via MotionConfig only for the corrected branch. No issues found.
packages/framer-motion/cypress/integration/reorder-scaled-parent.ts New Cypress spec pinning the correctParentTransform workaround. Two robustness concerns: the settle assertion silently skips when the computed transform is matrix3d(), and viewport-absolute getBoundingClientRect() values are used as element-relative trigger coordinates, diverging from the established pattern and making the incremental drag loop sensitive to animation timing.
plans/issues/README.md Status bump for issues #2449 and #2750 to VERIFIED-WORKAROUND; documentation change only.

Sequence Diagram

sequenceDiagram
    participant Cypress
    participant ReorderItem as Reorder.Item (item-0)
    participant MotionConfig
    participant correctParentTransform as correctParentTransform(ref)
    participant PanSession

    Cypress->>ReorderItem: pointerdown (startMidX, startMidY)
    ReorderItem->>PanSession: start pan session
    Cypress->>ReorderItem: pointermove (+5px threshold)
    Cypress->>ReorderItem: pointermove (+80px)
    PanSession->>correctParentTransform: transformPagePoint(pointerCoords)
    correctParentTransform-->>PanSession: inverse-matrix-corrected coords
    PanSession->>ReorderItem: apply corrected drag offset
    ReorderItem-->>Cypress: getBoundingClientRect() - screenDelta ~80px
    Cypress->>ReorderItem: pointerup
    Note over ReorderItem: settles: translateY to 0
Loading

Reviews (1): Last reviewed commit: "Pin correctParentTransform workaround fo..." | Re-trigger Greptile

Comment on lines +107 to +118
)
})

// ... and the released item should settle flush in the list, i.e. no
// stranded drag transform (translateY ≈ 0 in local space).
cy.get("[data-testid='item-0']").then(($el: any) => {
const transform = getComputedStyle($el[0]).transform
if (transform && transform !== "none") {
const parts = transform.match(/matrix\(([^)]+)\)/)
if (parts) {
const translateY = parseFloat(parts[1].split(",")[5])
expect(Math.abs(translateY)).to.be.lessThan(10)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Silent skip on matrix3d settle assertion

transform.match(/matrix\(([^)]+)\)/) only matches the 6-value 2D form. If the projection system produces a matrix3d(…) value (which it can when projection combines the layout correction with the drag offset), parts is null and the entire translate assertion is silently skipped — a non-settled item with a stranded matrix3d translateY would pass the test without any error. The fix is to also handle matrix3d (translateY is index 13 in the 16-element list), or simplify by parsing the translateY(…) shorthand that Framer Motion also emits, or using a permissive regex like /matrix3?d?\(([^)]+)\)/ and branching on value count.

Comment on lines +29 to +54
const startMidY = start.top + start.height / 2

cy.wrap($el)
.trigger("pointerdown", startMidX, startMidY, {
force: true,
})
.wait(50)
// Move past the drag threshold.
.trigger("pointermove", startMidX, startMidY + 5, {
force: true,
})
.wait(50)
// Move the pointer 80px down the screen.
.trigger("pointermove", startMidX, startMidY + 80, {
force: true,
})
.wait(80)
.then(([item]: any) => {
const moved = item.getBoundingClientRect()
const movedMidY = moved.top + moved.height / 2
const screenDelta = movedMidY - startMidY

// The element must follow the cursor (~80px on screen),
// not 80 / scale (160px) as it would without the
// transformPagePoint correction.
expect(screenDelta).to.be.greaterThan(60)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Viewport-absolute coordinates used as element-relative trigger offsets

startMidX and startMidY are obtained from getBoundingClientRect() (viewport-absolute values), then passed directly as x, y to .trigger(). Cypress adds the element's current getBoundingClientRect().left/top to those values when computing clientX/clientY, so the resulting pointer events fire at 2 * element.left + width/2 rather than at the element's centre. The displacement in client space still works out to the intended 80 px (the same absolute offset is added to both pointerdown and pointermove), and the final screenDelta comparison uses the same absolute baseline, so the first test passes.

The concern is the second test's incremental loop: Cypress re-evaluates the element's bounding rect at each .trigger(), so if the item has moved between ticks, the effective client-space delta grows beyond the intended i * 14 px. The existing tests in this repo (e.g., drag-scaled-parent.ts) use hardcoded element-relative coordinates (10, 10, 110, 110) to avoid this. Using relative coordinates like $el[0].getBoundingClientRect().width / 2 at capture time and passing small fixed deltas would be more robust.

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.

[BUG] Reorder - Moving all the time / Doesn't consider scale and origin of parent

1 participant