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
30 changes: 30 additions & 0 deletions dev/react/src/tests/layout-motion-value.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { motion, useMotionValue } from "framer-motion"

export const App = () => {
const width = useMotionValue(100)

return (
<>
<motion.div
id="box"
layout
style={{
width,
height: 100,
position: "absolute",
top: 0,
left: 0,
background: "red",
}}
transition={{ duration: 0.5, ease: () => 0.5 }}
/>
<button
id="toggle"
style={{ position: "absolute", top: 200 }}
onClick={() => width.set(width.get() === 100 ? 300 : 100)}
>
Toggle
</button>
</>
)
}
21 changes: 21 additions & 0 deletions packages/framer-motion/cypress/integration/layout-motion-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
describe("Layout animation with MotionValue", () => {
it("Triggers layout animation when MotionValue changes a layout-affecting property", () => {
cy.visit("?test=layout-motion-value")
.wait(50)
.get("#box")
.should(([$box]: any) => {
const bbox = $box.getBoundingClientRect()
expect(bbox.width).to.equal(100)
})
.get("#toggle")
.trigger("click")
.wait(50)
.get("#box")
.should(([$box]: any) => {
const bbox = $box.getBoundingClientRect()
// With ease: () => 0.5, layout animation freezes at 50%
// Width should be 200 (midpoint of 100→300), not 300 (no animation)
expect(bbox.width).to.equal(200)
})
})
})
21 changes: 19 additions & 2 deletions packages/motion-dom/src/render/VisualElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ import {
} from "./utils/reduced-motion"
import { resolveVariantFromProps } from "./utils/resolve-variants"

const layoutKeys = new Set([
"width",
"height",
"top",
"left",
"right",
"bottom",
Comment on lines 47 to +54
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 layoutKeys doesn't cover all layout-affecting CSS properties

The set only includes the six box-position/size properties listed here. Several other CSS properties that a user might drive with a MotionValue can equally affect an element's bounding box and therefore fail to trigger layout animations:

  • minWidth / maxWidth / minHeight / maxHeight
  • padding / paddingTop / paddingRight / paddingBottom / paddingLeft
  • margin / marginTop / marginRight / marginBottom / marginLeft
  • inset (CSS shorthand for top/right/bottom/left)
  • borderWidth and its long-hand variants

If this is an intentional trade-off (covering only the most common cases), it would be worth documenting with a comment so future contributors understand the scope.

])

const propEventHandlers = [
"AnimationStart",
"AnimationComplete",
Expand Down Expand Up @@ -567,6 +576,7 @@ export abstract class VisualElement<
}

const valueIsTransform = transformProps.has(key)
const valueIsLayout = !valueIsTransform && layoutKeys.has(key)

if (valueIsTransform && this.onBindTransform) {
this.onBindTransform()
Expand All @@ -579,8 +589,15 @@ export abstract class VisualElement<

this.props.onUpdate && frame.preRender(this.notifyUpdate)

if (valueIsTransform && this.projection) {
this.projection.isTransformDirty = true
if (this.projection) {
if (valueIsTransform) {
this.projection.isTransformDirty = true
} else if (valueIsLayout) {
this.projection.willUpdate()
frame.postRender(
() => this.projection?.root?.didUpdate()
)
Comment on lines +597 to +599
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 New anonymous function allocated on every onChange call

The postRender callback () => this.projection?.root?.didUpdate() is an arrow function created fresh each time the onChange handler fires. Because frame.postRender (backed by a Set<Process>) deduplicates by function reference, each distinct closure is stored separately. If onChange fires multiple times before the next postRender step runs, N distinct callbacks are enqueued and all executed — only the first didUpdate() call does real work (thanks to its updateScheduled guard), but the rest run needlessly.

A stable, pre-bound reference on the class (similar to how this.notifyUpdate is used for preRender) would ensure only one entry lives in the set at any time:

// as a class field:
private scheduleDidUpdate = () => this.projection?.root?.didUpdate()

// in the onChange handler:
frame.postRender(this.scheduleDidUpdate)

This mirrors the existing pattern for this.notifyUpdate and prevents unnecessary closure allocations on rapid value changes.

}
}

this.scheduleRender()
Expand Down