diff --git a/dev/react/src/tests/layout-scaled-parent-css.tsx b/dev/react/src/tests/layout-scaled-parent-css.tsx
new file mode 100644
index 0000000000..85ce83a2b6
--- /dev/null
+++ b/dev/react/src/tests/layout-scaled-parent-css.tsx
@@ -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 (
+
+
+ 0.5 }}
+ />
+
+ setToggled((s) => !s)}
+ style={{
+ position: "absolute",
+ top: 500,
+ left: 0,
+ width: 100,
+ height: 50,
+ background: "blue",
+ cursor: "pointer",
+ zIndex: 10,
+ }}
+ >
+ Toggle
+
+
+ )
+}
diff --git a/dev/react/src/tests/layout-scaled-parent.tsx b/dev/react/src/tests/layout-scaled-parent.tsx
new file mode 100644
index 0000000000..f0c1a193bb
--- /dev/null
+++ b/dev/react/src/tests/layout-scaled-parent.tsx
@@ -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 (
+
+
+ 0.5 }}
+ />
+
+ setToggled((s) => !s)}
+ style={{
+ position: "absolute",
+ top: 500,
+ left: 0,
+ width: 100,
+ height: 50,
+ background: "blue",
+ cursor: "pointer",
+ zIndex: 10,
+ }}
+ >
+ Toggle
+
+
+ )
+}
diff --git a/packages/framer-motion/cypress/integration/layout-scaled-parent-css.ts b/packages/framer-motion/cypress/integration/layout-scaled-parent-css.ts
new file mode 100644
index 0000000000..61ab79888b
--- /dev/null
+++ b/packages/framer-motion/cypress/integration/layout-scaled-parent-css.ts
@@ -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)
+ })
+ })
+})
diff --git a/packages/framer-motion/cypress/integration/layout-scaled-parent.ts b/packages/framer-motion/cypress/integration/layout-scaled-parent.ts
new file mode 100644
index 0000000000..af58a32a8f
--- /dev/null
+++ b/packages/framer-motion/cypress/integration/layout-scaled-parent.ts
@@ -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)
+ })
+ })
+})
diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts
index 80d311d0f0..cad3963b8c 100644
--- a/packages/motion-dom/src/projection/node/create-projection-node.ts
+++ b/packages/motion-dom/src/projection/node/create-projection-node.ts
@@ -86,6 +86,46 @@ const animationTarget = 1000
let id = 0
+/**
+ * Parse a CSS transform string to extract scale values.
+ * Returns ResolvedValues with scale/scaleX/scaleY and originX/originY if found.
+ */
+function parseCSSTransformScale(
+ transform: string,
+ transformOrigin?: string
+): ResolvedValues | undefined {
+ // Match scale(N) or scale(X, Y)
+ const scaleMatch = transform.match(
+ /scale\(\s*([0-9.e-]+)(?:\s*,\s*([0-9.e-]+))?\s*\)/
+ )
+ if (!scaleMatch) return undefined
+
+ const values: ResolvedValues = {}
+ if (scaleMatch[2] !== undefined) {
+ values.scaleX = parseFloat(scaleMatch[1])
+ values.scaleY = parseFloat(scaleMatch[2])
+ } else {
+ values.scale = parseFloat(scaleMatch[1])
+ }
+
+ if (transformOrigin) {
+ const parseKeyword = (v: string): number | undefined => {
+ if (v === "left" || v === "top") return 0
+ if (v === "center") return 0.5
+ if (v === "right" || v === "bottom") return 1
+ if (v.endsWith("%")) return parseFloat(v) / 100
+ return undefined
+ }
+ const parts = transformOrigin.trim().split(/\s+/)
+ const ox = parseKeyword(parts[0])
+ const oy = parseKeyword(parts[1] ?? parts[0])
+ if (ox !== undefined) values.originX = ox
+ if (oy !== undefined) values.originY = oy
+ }
+
+ return values
+}
+
function resetDistortingTransform(
key: string,
visualElement: VisualElement,
@@ -339,6 +379,12 @@ export function createProjectionNode({
*/
shouldResetTransform = false
+ /**
+ * Flag set when this node's transform was reset for measurement.
+ * Used to avoid double-removing transforms in removeTransform().
+ */
+ wasTransformReset = false
+
/**
* Store whether this node has been checked for optimised appear animations. As
* effects fire bottom-up, and we want to look up the tree for appear animations,
@@ -357,6 +403,13 @@ export function createProjectionNode({
*/
treeScale: Point = { x: 1, y: 1 }
+ /**
+ * Parsed CSS transform values from the element's style.transform prop.
+ * Used when a CSS transform string (e.g. "scale(2)") is applied to a
+ * motion element but not tracked by the motion value system.
+ */
+ cssTransformValues?: ResolvedValues
+
/**
* Is hydrated with a projection node if an element is animating from another.
*/
@@ -646,6 +699,41 @@ export function createProjectionNode({
return visualElement && visualElement.getProps().transformTemplate
}
+ /**
+ * Parse CSS transform values from the element's style prop.
+ * This detects CSS transform strings like "scale(2)" that aren't
+ * tracked by the motion value system, so the projection system
+ * can account for them during layout animations.
+ */
+ updateCSSTransformValues() {
+ if (hasTransform(this.latestValues)) {
+ this.cssTransformValues = undefined
+ return
+ }
+
+ const { visualElement } = this.options
+ if (!visualElement) return
+
+ const style = (visualElement.getProps() as any).style
+ if (!style) return
+
+ const cssTransform = resolveMotionValue(
+ style.transform as any
+ ) as string | undefined
+
+ if (!cssTransform || typeof cssTransform !== "string") {
+ this.cssTransformValues = undefined
+ return
+ }
+
+ this.cssTransformValues = parseCSSTransformScale(
+ cssTransform,
+ resolveMotionValue(style.transformOrigin as any) as
+ | string
+ | undefined
+ )
+ }
+
willUpdate(shouldNotifyListeners = true) {
this.root.hasTreeAnimated = true
@@ -682,6 +770,12 @@ export function createProjectionNode({
const node = this.path[i]
node.shouldResetTransform = true
+ /**
+ * Detect CSS transform strings on ancestor nodes so the
+ * projection system can account for them.
+ */
+ node.updateCSSTransformValues()
+
/**
* Percentage translates resolve against layoutBox dimensions,
* so ancestors with them must be re-measured after transform reset.
@@ -941,6 +1035,8 @@ export function createProjectionNode({
resetTransform() {
if (!resetTransform) return
+ this.wasTransformReset = false
+
const isResetRequested =
this.isLayoutDirty ||
this.shouldResetTransform ||
@@ -962,10 +1058,12 @@ export function createProjectionNode({
this.instance &&
(hasProjection ||
hasTransform(this.latestValues) ||
+ this.cssTransformValues ||
transformTemplateHasChanged)
) {
resetTransform(this.instance, transformTemplateValue)
this.shouldResetTransform = false
+ this.wasTransformReset = true
this.scheduleRender()
}
}
@@ -1067,7 +1165,16 @@ export function createProjectionNode({
})
}
- if (!hasTransform(node.latestValues)) continue
+ if (!hasTransform(node.latestValues)) {
+ if (node.cssTransformValues) {
+ transformBox(
+ withTransforms,
+ node.cssTransformValues,
+ node.layout?.layoutBox
+ )
+ }
+ continue
+ }
transformBox(
withTransforms,
node.latestValues,
@@ -1081,6 +1188,12 @@ export function createProjectionNode({
this.latestValues,
this.layout?.layoutBox
)
+ } else if (this.cssTransformValues) {
+ transformBox(
+ withTransforms,
+ this.cssTransformValues,
+ this.layout?.layoutBox
+ )
}
return withTransforms
@@ -1092,26 +1205,41 @@ export function createProjectionNode({
for (let i = 0; i < this.path.length; i++) {
const node = this.path[i]
- if (!hasTransform(node.latestValues)) continue
+
+ /**
+ * If this node's transform was already removed from the DOM
+ * by resetTransform(), skip it to avoid double-removing.
+ */
+ if (node.wasTransformReset) continue
+
+ const hasCSSTransform = node.cssTransformValues && hasScale(node.cssTransformValues)
+
+ if (!hasTransform(node.latestValues) && !hasCSSTransform) continue
+
+ const values = hasTransform(node.latestValues)
+ ? node.latestValues
+ : node.cssTransformValues!
let sourceBox: Box | undefined
if (node.instance) {
- hasScale(node.latestValues) && node.updateSnapshot()
+ hasScale(values) && node.updateSnapshot()
sourceBox = createBox()
copyBoxInto(sourceBox, node.measurePageBox())
}
removeBoxTransforms(
boxWithoutTransform,
- node.latestValues,
+ values,
node.snapshot?.layoutBox,
sourceBox
)
}
- if (hasTransform(this.latestValues)) {
+ if (hasTransform(this.latestValues) && !this.wasTransformReset) {
removeBoxTransforms(boxWithoutTransform, this.latestValues)
+ } else if (this.cssTransformValues && hasScale(this.cssTransformValues) && !this.wasTransformReset) {
+ removeBoxTransforms(boxWithoutTransform, this.cssTransformValues)
}
return boxWithoutTransform
@@ -1434,6 +1562,22 @@ export function createProjectionNode({
isShared
)
+ /**
+ * Include ancestor CSS transform scales in treeScale.
+ * These are from CSS transform strings (e.g. "scale(2)") that
+ * aren't tracked by the motion value system but still affect
+ * the coordinate space of children.
+ */
+ for (let i = 0; i < this.path.length; i++) {
+ const css = this.path[i].cssTransformValues
+ if (!css) continue
+ const s = (css.scale as number) ?? 1
+ const sx = (css.scaleX as number) ?? 1
+ const sy = (css.scaleY as number) ?? 1
+ this.treeScale.x *= s * sx
+ this.treeScale.y *= s * sy
+ }
+
/**
* If this layer needs to perform scale correction but doesn't have a target,
* use the layout as the target.
@@ -1771,6 +1915,14 @@ export function createProjectionNode({
*/
transformBox(targetWithTransforms, latestValues)
+ /**
+ * If the lead has CSS transform values (e.g. transform: "scale(2)"),
+ * apply those to the target as well so the projection delta accounts for them.
+ */
+ if (lead.cssTransformValues) {
+ transformBox(targetWithTransforms, lead.cssTransformValues, layout.layoutBox)
+ }
+
/**
* Update the delta between the corrected box and the final target box, after
* user-set transforms are applied to it. This will be used by the renderer to
diff --git a/packages/motion-dom/src/projection/node/types.ts b/packages/motion-dom/src/projection/node/types.ts
index 6301624c47..6d4685df43 100644
--- a/packages/motion-dom/src/projection/node/types.ts
+++ b/packages/motion-dom/src/projection/node/types.ts
@@ -63,12 +63,15 @@ export interface IProjectionNode {
projectionDelta?: Delta
projectionDeltaWithTransform?: Delta
latestValues: ResolvedValues
+ cssTransformValues?: ResolvedValues
+ updateCSSTransformValues(): void
isLayoutDirty: boolean
isProjectionDirty: boolean
isSharedProjectionDirty: boolean
isTransformDirty: boolean
resolvedRelativeTargetAt?: number
shouldResetTransform: boolean
+ wasTransformReset: boolean
prevTransformTemplateValue: string | undefined
isUpdateBlocked(): boolean
updateManuallyBlocked: boolean