Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
48 changes: 48 additions & 0 deletions dev/react/src/tests/drag-variant-transform.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { motion, useDragControls, Variants } from "framer-motion"

const variants: Variants = {
none: (custom?: string) => ({
width: "fit-content",
height: "fit-content",
transform: custom || undefined,
}),
}

// Regression test for #2807: a variant that sets a literal `transform` string
// should not prevent drag from moving the element.
export const App = () => {
const dragControls = useDragControls()

return (
<div style={{ height: 800, padding: 100 }}>
<button
data-testid="handle"
onPointerDown={(e) => dragControls.start(e)}
style={{
display: "block",
width: 100,
height: 20,
background: "blue",
marginBottom: 10,
}}
/>
<motion.div
data-testid="draggable"
custom="translate(50px, 60px)"
initial="none"
animate="none"
variants={variants}
drag
dragListener={false}
dragControls={dragControls}
dragMomentum={false}
style={{
background: "red",
padding: 30,
}}
>
window
</motion.div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Regression test for #2807: a variant that sets a literal `transform` string
* should not prevent drag from moving the element.
*/
describe("Drag with variant-set transform", () => {
it("Drags the element even when a variant sets a literal transform string", () => {
cy.visit("?test=drag-variant-transform")

// The variant applies `transform: "translate(50px, 60px)"` so the
// initial bounding rect reflects that translation (padding 100 + 50).
cy.get("[data-testid='draggable']")
.wait(200)
.then(($el: any) => {
expect(
$el[0].getBoundingClientRect().left,
"initial transform from variant is applied"
).to.equal(150)
})

cy.get("[data-testid='handle']")
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 250, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

cy.get("[data-testid='draggable']").should(($el: any) => {
const { left, top } = $el[0].getBoundingClientRect()
// Drag moved the pointer by ~195/245 px. The element should have
// moved by the same amount from its initial position.
expect(left, "left after drag").to.be.greaterThan(250)
expect(top, "top after drag").to.be.greaterThan(250)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ describe("buildHTMLStyles", () => {
transform: "translateY(2) translateX(1px)",
})
})

test("Individual transform props take precedence over transform string", () => {
const latest = { x: 50, transform: "translateX(100px)" }
const style = {}
build(latest, { style })

expect(style).toEqual({
transform: "translateX(50px)",
})
})
})

interface BuildProps {
Expand Down
13 changes: 11 additions & 2 deletions packages/motion-dom/src/render/html/utils/build-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,23 @@ export function buildHTMLStyles(
}
}

if (!latestValues.transform) {
/**
* If x or y motion values are present they take precedence over any
* `transform` string in latestValues. This allows drag (and other gestures
* that drive x/y) to keep working when a variant or style sets a literal
* `transform` string. See #2807.
*/
const hasTranslate =
latestValues.x !== undefined || latestValues.y !== undefined
Comment on lines +58 to +59
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 The hasTranslate guard only checks x and y, but transformPropOrder also exposes z, translateX, translateY, and translateZ as valid motion value keys. If an element uses z-axis gestures, or if a consumer drives translateX/translateY directly instead of x/y, a literal transform string in latestValues would still silently win over those individual props — reproducing the same bug as #2807. Extending the check to cover those aliases keeps the fix consistent across all translate axes.

Suggested change
const hasTranslate =
latestValues.x !== undefined || latestValues.y !== undefined
const hasTranslate =
latestValues.x !== undefined ||
latestValues.y !== undefined ||
latestValues.z !== undefined ||
latestValues.translateX !== undefined ||
latestValues.translateY !== undefined ||
latestValues.translateZ !== undefined


if (!latestValues.transform || hasTranslate) {
if (hasTransform || transformTemplate) {
style.transform = buildTransform(
latestValues,
state.transform,
transformTemplate
)
} else if (style.transform) {
} else if (!latestValues.transform && style.transform) {
/**
* If we have previously created a transform but currently don't have any,
* reset transform style to none.
Expand Down