Skip to content

Fix layout animation bouncing when child size changes with explicit duration#3640

Open
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-3028
Open

Fix layout animation bouncing when child size changes with explicit duration#3640
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-3028

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • Fixes layout animation bouncing when a parent has an explicit transition duration and a child with layout also changes size
  • Child layout animations now inherit the closest projecting parent's transition when they don't have their own, keeping parent and child animations synchronized
  • Transition clearing is deferred to a separate pass after all layout update notifications, so children can read the parent's transition during setup

Bug

When transition={{ duration: 1 }} is set on a parent layout element and a child with layout changes size, the child uses the default layout transition (0.45s) while the parent uses 1s. This timing mismatch causes the child's relative position to interpolate at a different rate than the parent's size changes, producing visible bouncing/jittering.

Root cause

In notifyLayoutUpdate, each node resolves its layout transition independently:

const layoutTransition =
    this.options.transition ||
    visualElement.getDefaultTransition() ||
    defaultLayoutTransition // { duration: 0.45 }

The child's relativeTarget (its position within the parent) is mixed using the child's own animation progress. When the child's animation finishes at 0.45s while the parent is still at ~45% progress, the child's absolute coordinates no longer correspond to the correct proportional position within the parent's intermediate size.

Additionally, parent transitions were cleared in notifyLayoutUpdate before children could read them, since nodes are processed depth-first.

Fix

  1. Added parent transition fallback in the layout transition resolution chain
  2. Extracted transition clearing into a separate clearTransition pass that runs after all notifyLayoutUpdate callbacks

Fixes #3028

Test plan

  • Added Cypress E2E test (layout-child-scale-correction-duration) that records min/max child dimensions during animation and verifies they stay within bounds
  • All 779 unit tests pass
  • All existing layout Cypress tests pass (React 18 + React 19)
  • New test passes on React 18 and React 19

🤖 Generated with Claude Code

When a parent layout element has an explicit transition duration and a
child also has layout, the child would use the default layout transition
(0.45s) while the parent used the explicit duration. This timing mismatch
caused the child's relative position to interpolate at a different rate
than the parent's size, resulting in bouncing.

Two changes:
1. Child layout animations now inherit the closest projecting parent's
   transition when they don't have their own explicit transition.
2. Transition clearing is deferred to a separate pass after all layout
   animations start, so children can read the parent's transition.

Fixes #3028

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR fixes layout animation bouncing (issue #3028) that occurred when a parent element had an explicit transition duration and a child with layout also changed size. Two targeted changes are made to create-projection-node.ts:

  • Parent transition inheritance: A new fallback this.getClosestProjectingParent()?.options.transition is inserted into the layoutTransition resolution chain, so a child with no explicit transition inherits the closest projecting parent's transition, keeping both animations in sync.
  • Deferred transition clearing: node.options.transition = undefined is extracted from notifyLayoutUpdate into a new clearTransition helper and called in a separate forEach pass after all notifyLayoutUpdate callbacks complete. This ensures children can read the parent's transition during their own notifyLayoutUpdate.

The fix is logically correct for the fresh-start animation case: FlatTree iterates nodes by ascending depth (parent-before-child), startAnimation is deferred to frame.update so latestValues is not mutated synchronously, and the separate clearTransition pass preserves the parent's options.transition until all children have had a chance to read it. A Cypress E2E test and a React fixture file are added to reproduce and verify the fix.

Confidence Score: 4/5

  • This PR is safe to merge — the fix is well-scoped, logically sound for the primary bug scenario, and backed by a new E2E test.
  • The core changes are minimal (3 lines of logic + helper function extraction) and correct given the existing FlatTree depth-ordering guarantee and deferred startAnimation behaviour. The only non-critical caveat is that parent transition inheritance is silently bypassed when a mid-animation re-trigger leaves non-identity scale in latestValues, which is a pre-existing limitation not introduced by this PR. The test has a minor flakiness risk from a fixed .wait(50) but the overall approach is sound.
  • No files require special attention; packages/motion-dom/src/projection/node/create-projection-node.ts carries the core logic change and is the most important file to review.

Important Files Changed

Filename Overview
packages/motion-dom/src/projection/node/create-projection-node.ts Core fix: adds parent transition fallback in layout transition resolution and defers transition clearing to a second forEach pass, so children can read the parent's options.transition while their own notifyLayoutUpdate runs. Logic is sound given that FlatTree iterates depth-first and startAnimation defers to frame.update (never mutates latestValues synchronously). One subtle limitation: getClosestProjectingParent returns undefined when the parent already has non-identity scale in latestValues, so the inheritance only reliably fires on fresh-start animations, not mid-animation re-triggers.
packages/framer-motion/cypress/integration/layout-child-scale-correction-duration.ts E2E test that verifies child size stays within [38, 62] during the 1 s parent animation. Covers the reported bug scenario. Tolerances are reasonable; wait time of 1200 ms is sufficient for the 1 s animation.
dev/react/src/tests/layout-child-scale-correction-duration.tsx Minimal reproduction fixture for #3028. Records min/max child bounding rect dimensions via rAF loop and exposes them on a tracker element for Cypress to assert against. Straightforward and correct.

Sequence Diagram

sequenceDiagram
    participant Root as Root Node
    participant FlatTree as FlatTree (depth-sorted)
    participant Parent as Parent ProjectionNode
    participant Child as Child ProjectionNode
    participant Frame as frame.update (next tick)

    Root->>FlatTree: forEach(notifyLayoutUpdate)
    FlatTree->>Parent: notifyLayoutUpdate(parent)
    Note over Parent: Reads options.transition → {duration:1}<br/>Fires "didUpdate" listener<br/>Calls startAnimation() — deferred to frame.update
    Parent-->>FlatTree: done (options.transition still set)
    FlatTree->>Child: notifyLayoutUpdate(child)
    Note over Child: options.transition → undefined<br/>getDefaultTransition() → undefined<br/>getClosestProjectingParent()?.options.transition → {duration:1} ✓<br/>Fires "didUpdate" listener<br/>Calls startAnimation() with duration:1
    Child-->>FlatTree: done
    Root->>FlatTree: forEach(clearTransition)  [NEW separate pass]
    FlatTree->>Parent: node.options.transition = undefined
    FlatTree->>Child: node.options.transition = undefined
    Root->>Frame: frameSteps.update.process() — animations begin<br/>latestValues updated here (after both nodes set up)
Loading

Last reviewed commit: 35c8a01

Comment on lines +512 to +513
this.getClosestProjectingParent()?.options
.transition ||
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parent transition inheritance silently breaks for mid-animation re-triggers

getClosestProjectingParent() returns undefined when the closest projecting parent has a non-identity scale, scaleX, scaleY, x, or y in latestValues (lines 1318–1319). During the notifyLayoutUpdate pass, startAnimation defers the actual animation to frame.update, so latestValues is safe for a fresh-start trigger. However, if the user re-triggers the layout animation while one is already in progress, the parent node will already have non-identity scale/translate in latestValues, causing getClosestProjectingParent() to return undefined and silently falling through to defaultLayoutTransition instead of the parent's transition.

This is not a regression (the original code had the same limitation), but given that the PR description frames this as a general fix for duration synchronisation, it may be worth noting this edge case in a comment so a future reader understands the boundary condition.

Suggested change
this.getClosestProjectingParent()?.options
.transition ||
this.options.transition ||
visualElement.getDefaultTransition() ||
// Inherits the closest projecting parent's transition so parent and
// child layout animations stay synchronised. Note: this fallback is
// skipped when the parent already has non-identity scale/translate in
// latestValues (e.g. during a mid-animation re-trigger), because
// getClosestProjectingParent returns undefined in that case.
this.getClosestProjectingParent()?.options
.transition ||
defaultLayoutTransition

Comment on lines +7 to +9
// Wait for full animation to complete (parent: 1s)
.wait(1200)
.get("#tracker")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.wait(50) before click may be insufficient on slow CI runners

The 50 ms wait is intended to let the page settle before triggering the animation. On slower CI machines or when the test browser takes longer to mount React, 50 ms may not be enough, making the test flaky. The existing layout tests in this repo typically use a #trigger element or wait for a specific DOM condition rather than an arbitrary delay. Consider waiting for the #parent element to be visible instead:

Suggested change
// Wait for full animation to complete (parent: 1s)
.wait(1200)
.get("#tracker")
cy.visit("?test=layout-child-scale-correction-duration")
.get("#parent")
.should("be.visible")
.trigger("click")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Layout animations: bouncing occurs when duration is set, and the child size changes

1 participant