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