Skip to content
Closed
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
64 changes: 64 additions & 0 deletions dev/react/src/tests/layout-scaled-parent-css.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { motion } from "framer-motion"
import { useState } from "react"

/**
* Reproduction for #3356: Layout animations misaligned in scaled parent containers.
*
* A parent motion.div has layoutRoot and CSS transform: scale(2) with
* transformOrigin: "top left". A child motion.div with layout toggles
* its CSS top position. The layout animation should smoothly interpolate
* between the two visual positions.
*
* This tests the case where scale is applied via CSS transform string
* (not motion values), which is how the original bug reporter set it up.
*/
export const App = () => {
const [toggled, setToggled] = useState(false)

return (
<div style={{ padding: 0, margin: 0 }}>
<motion.div
layoutRoot
style={{
transform: "scale(2)",
transformOrigin: "top left",
position: "absolute",
top: 0,
left: 0,
width: 200,
height: 200,
}}
>
<motion.div
id="child"
layout
style={{
position: "absolute",
top: toggled ? 50 : 0,
left: 0,
width: 50,
height: 50,
background: "red",
}}
transition={{ duration: 10, ease: () => 0.5 }}
/>
</motion.div>
<div
id="toggle"
onClick={() => setToggled((s) => !s)}
style={{
position: "absolute",
top: 500,
left: 0,
width: 100,
height: 50,
background: "blue",
cursor: "pointer",
zIndex: 10,
}}
>
Toggle
</div>
</div>
)
}
66 changes: 66 additions & 0 deletions dev/react/src/tests/layout-scaled-parent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { motion } from "framer-motion"
import { useState } from "react"

/**
* Reproduction for #3356: Layout animations misaligned in scaled parent containers.
*
* A parent motion.div has layoutRoot and style={{ scale: 2 }}.
* A child motion.div with layout toggles its CSS top position.
* The layout animation should smoothly interpolate between the two visual positions.
*
* With the bug, the projection system doesn't account for the parent's scale
* when computing layout animation deltas, causing misaligned animations.
* Using originX/originY as motion values (not CSS transformOrigin) ensures
* removeTransform uses the correct origin.
*/
export const App = () => {
const [toggled, setToggled] = useState(false)

return (
<div style={{ padding: 0, margin: 0 }}>
<motion.div
layoutRoot
style={{
scale: 2,
originX: 0,
originY: 0,
position: "absolute",
top: 0,
left: 0,
width: 200,
height: 200,
}}
>
<motion.div
id="child"
layout
style={{
position: "absolute",
top: toggled ? 50 : 0,
left: 0,
width: 50,
height: 50,
background: "red",
}}
transition={{ duration: 10, ease: () => 0.5 }}
/>
</motion.div>
<div
id="toggle"
onClick={() => setToggled((s) => !s)}
style={{
position: "absolute",
top: 500,
left: 0,
width: 100,
height: 50,
background: "blue",
cursor: "pointer",
zIndex: 10,
}}
>
Toggle
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
describe("Layout animation in CSS-scaled parent", () => {
it("Correctly animates layout when parent has CSS transform: scale()", () => {
/**
* #3356: When a parent has CSS transform: scale(2) (as a string,
* not a motion value) and layoutRoot, children's layout animations
* should account for the parent scale.
*
* Setup:
* Parent: layoutRoot, CSS transform: "scale(2)", transformOrigin: "top left"
* Child: layout, toggles CSS top from 0 to 50
* Transition: duration 10s, ease: () => 0.5 (constant midpoint)
*
* In viewport coordinates (parent scale 2):
* Start: child at viewport top=0 (CSS top=0 * 2)
* End: child at viewport top=100 (CSS top=50 * 2)
* Midpoint: child at viewport top=50
*
* Bug behavior: the projection system doesn't see the parent's CSS
* transform string, so treeScale is 1 instead of 2, causing the
* translateY to be over-applied.
*/
cy.visit("?test=layout-scaled-parent-css")
.wait(100)
.get("#child")
.should(([$child]: any) => {
const bbox = $child.getBoundingClientRect()
// Initial: CSS top=0 in parent space → viewport top=0
expect(bbox.top).to.equal(0)
// Width/height doubled by parent scale(2)
expect(bbox.width).to.equal(100)
expect(bbox.height).to.equal(100)
})
.get("#toggle")
.trigger("click")
.wait(200)
.get("#child")
.should(([$child]: any) => {
const bbox = $child.getBoundingClientRect()
/**
* During animation with constant ease 0.5:
* Expected: viewport top ≈ 50 (midpoint between 0 and 100)
* Bug: viewport top is wrong because CSS scale is invisible
* to the projection system
*/
expect(bbox.top).to.be.greaterThan(30)
expect(bbox.top).to.be.lessThan(70)
})
})
})
47 changes: 47 additions & 0 deletions packages/framer-motion/cypress/integration/layout-scaled-parent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
describe("Layout animation in scaled parent", () => {
it("Correctly animates layout in a CSS-scaled layoutRoot parent", () => {
/**
* #3356: When a parent has CSS transform: scale(2) and layoutRoot,
* children's layout animations should account for the parent scale.
*
* Setup:
* Parent: layoutRoot, transform: scale(2), transformOrigin: 0 0
* Child: layout, toggles CSS top from 0 to 50
* Transition: duration 10s, ease: () => 0.5 (constant midpoint)
*
* In viewport coordinates (parent scale 2):
* Start: child at viewport top=0 (CSS top=0 * 2)
* End: child at viewport top=100 (CSS top=50 * 2)
* Midpoint: child at viewport top=50
*
* Bug behavior: treeScale doesn't include parent CSS scale,
* so the translateY is over-applied by a factor of 2, causing the
* child to appear at viewport top=0 instead of top=50.
*/
cy.visit("?test=layout-scaled-parent")
.wait(50)
.get("#child")
.should(([$child]: any) => {
const bbox = $child.getBoundingClientRect()
// Initial: CSS top=0 in parent space → viewport top=0
expect(bbox.top).to.equal(0)
// Width/height doubled by parent scale(2)
expect(bbox.width).to.equal(100)
expect(bbox.height).to.equal(100)
})
.get("#toggle")
.trigger("click")
.wait(100)
.get("#child")
.should(([$child]: any) => {
const bbox = $child.getBoundingClientRect()
/**
* During animation with constant ease 0.5:
* Expected: viewport top ≈ 50 (midpoint between 0 and 100)
* Bug: viewport top ≈ 0 (translation over-applied by parent scale)
*/
expect(bbox.top).to.be.greaterThan(40)
expect(bbox.top).to.be.lessThan(60)
})
})
})
Loading