Skip to content

Include parent CSS scales in treeScale for layout animations#3623

Closed
mattgperry wants to merge 3 commits intomainfrom
worktree-fix-issue-3356
Closed

Include parent CSS scales in treeScale for layout animations#3623
mattgperry wants to merge 3 commits intomainfrom
worktree-fix-issue-3356

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • Fixes layout animations being misaligned when parent elements have CSS transform: scale() applied alongside layoutRoot
  • The projection system's treeScale only accumulated projectionDelta scales (from layout animation), missing CSS-only scales entirely
  • Detects CSS-only scales during layout measurement by comparing getBoundingClientRect with offsetWidth/offsetHeight and includes them in treeScale during tree delta accumulation

Fixes #3356

How it works

When a parent has style={{ transform: 'scale(2)' }} (not a motion value), child translations in buildProjectionTransform are divided by treeScale to compensate for ancestor scaling of the coordinate space. Without this fix, treeScale was {1, 1} for CSS-only scales, causing translations to be over-applied by the parent's scale factor (e.g. a 50px translation becomes 100px in viewport space inside a scale(2) parent).

The fix:

  1. In updateLayout(), computes each node's cumulative CSS scale from measuredBox / offsetWidth (only for nodes without motion value scales, to avoid double-counting)
  2. In applyTreeDeltas(), decomposes cumulative CSS scales into per-node contributions and multiplies them into treeScale

Test plan

  • Added Cypress E2E test (layout-scaled-parent.ts) that verifies layout animation position inside a scale(2) layoutRoot parent
  • Test fails before fix (child at viewport top=0 instead of expected top≈50)
  • Test passes after fix on both React 18 and React 19
  • All existing layout Cypress tests pass (16/16)
  • All unit tests pass (764 passed, 91 suites)
  • yarn build succeeds

🤖 Generated with Claude Code

@greptile-apps
Copy link

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR fixes layout animations being misaligned when child elements animate inside a parent that has a static CSS transform: scale() applied alongside layoutRoot. The root cause was that treeScale (used to compensate ancestor scaling in buildProjectionTransform) only accumulated scales from projectionDelta (layout animation corrections), completely missing CSS-only scales.

Key changes:

  • In updateLayout(), each node now measures its cumulative CSS scale by comparing getBoundingClientRect dimensions (measuredBox) against offsetWidth/offsetHeight. This ratio captures all ancestor CSS transforms (and non-tracked motion transforms) accumulated at that point in the tree.
  • In applyTreeDeltas(), a running prevCssScaleX/Y pair decomposes each node's cumulative cssScale into its own per-node contribution, which is then multiplied into treeScale.
  • Nodes that already track scale as motion values (hasScale(latestValues)) are skipped to avoid double-counting their own scale; however, their scale is still implicitly captured in descendant nodes' cssScale values through the getBoundingClientRect ratio (see note below).
  • SVG elements are excluded since offsetWidth/offsetHeight are not reliable on SVG.
  • A new Cypress E2E test and React test fixture cover the primary bug scenario.

Notable design consideration: The getBoundingClientRect / offsetWidth ratio captures all ancestor transforms visible in the rendered DOM — not only static CSS transforms, but also any motion-value transforms that are currently applied on ancestor elements (since resetTransform() restores latestValues-based transforms to the DOM before measurement). This is actually correct behavior since all such scales affect the coordinate space for child translations. However, the inline comments describe this as detecting only "CSS-only scales," which is slightly misleading.

Minor bug: When offsetWidth or offsetHeight equals zero (e.g., an element sized to width: 0), the cssScale property is not reset to undefined within the inner branch. If the element previously had a non-zero size with a CSS scale, the stale cssScale value persists and will incorrectly influence treeScale for any descendant layout animations.

Confidence Score: 4/5

  • This PR is safe to merge with a minor stale-value edge case to address.
  • The core fix is logically correct: using getBoundingClientRect/offsetWidth to detect cumulative CSS scales and decomposing them per-node in applyTreeDeltas accurately adds the missing scale factor to treeScale. The approach correctly handles multi-level CSS scale chains and is guarded against SVG elements and self-tracked motion value scales. The E2E test directly validates the primary bug scenario. The only identified issue is that cssScale is not reset to undefined when offsetWidth/offsetHeight is zero, which could leave a stale value from a prior measurement in narrow edge cases (zero-width elements inside scaled containers).
  • packages/motion-dom/src/projection/node/create-projection-node.ts — the cssScale is not cleared when offsetWidth/offsetHeight is 0

Important Files Changed

Filename Overview
packages/motion-dom/src/projection/node/create-projection-node.ts Adds cssScale measurement in updateLayout() by comparing getBoundingClientRect dimensions to offsetWidth/offsetHeight. Contains a minor bug where cssScale is not cleared when offsetWidth/offsetHeight is 0, potentially leaving stale values from a previous measurement.
packages/motion-dom/src/projection/geometry/delta-apply.ts Adds prevCssScaleX/Y tracking variables and decomposes cumulative cssScale per node into its own contribution before multiplying into treeScale. Logic is sound: since cssScale is cumulative, dividing by prevCssScaleX correctly isolates each node's own CSS scale contribution.
packages/motion-dom/src/projection/node/types.ts Adds optional cssScale: Point field to the IProjectionNode interface. Clean, minimal type addition.
packages/framer-motion/cypress/integration/layout-scaled-parent.ts Adds Cypress E2E test verifying layout animation correctness inside a CSS scale(2) layoutRoot parent. Test uses a constant-midpoint ease function (ease: () => 0.5) to deterministically freeze the animation at 50% progress, making viewport position assertions reliable.
dev/react/src/tests/layout-scaled-parent.tsx New React test fixture reproducing issue #3356. Parent has layoutRoot + CSS transform: scale(2), transformOrigin: 0 0. Child toggles top between 0 and 50, with a 10s transition frozen at midpoint by constant ease function.

Sequence Diagram

sequenceDiagram
    participant Root as Root Node (update)
    participant Node as Each Projection Node
    participant DOM as DOM (getBoundingClientRect)
    participant DeltaApply as applyTreeDeltas

    Root->>Node: resetTransformStyle()<br/>(restores latestValues transforms to DOM)
    Root->>Node: updateLayout()
    Node->>DOM: measure(false) → measuredBox<br/>(getBoundingClientRect, includes all ancestor transforms)
    Node->>DOM: offsetWidth / offsetHeight<br/>(layout box, no transforms)
    Node->>Node: if !hasScale(latestValues) && !isSVG<br/>  cssScale = measuredBox / offsetWidth
    Note over Node: cssScale is CUMULATIVE<br/>(includes all ancestor CSS+motion scales)

    Root->>DeltaApply: applyTreeDeltas(box, treeScale, treePath)
    loop for each node in treePath (root → leaf)
        DeltaApply->>DeltaApply: treeScale *= delta.x.scale (projectionDelta)
        alt node.cssScale is set
            DeltaApply->>DeltaApply: ownCssScale = node.cssScale / prevCssScale
            DeltaApply->>DeltaApply: treeScale *= ownCssScale
            DeltaApply->>DeltaApply: prevCssScale = node.cssScale
        end
    end
    DeltaApply-->>Root: treeScale now includes CSS scales
Loading

Last reviewed commit: 47c5e60

Comment on lines +929 to +944
if (offsetWidth && offsetHeight) {
const { measuredBox } = this.layout
const measuredWidth =
measuredBox.x.max - measuredBox.x.min
const measuredHeight =
measuredBox.y.max - measuredBox.y.min
const scaleX = measuredWidth / offsetWidth
const scaleY = measuredHeight / offsetHeight
if (
Math.abs(scaleX - 1) > 0.0001 ||
Math.abs(scaleY - 1) > 0.0001
) {
this.cssScale = { x: scaleX, y: scaleY }
} else {
this.cssScale = undefined
}
Copy link

Choose a reason for hiding this comment

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

Stale cssScale when offsetWidth/offsetHeight is zero

When offsetWidth or offsetHeight is falsy (zero), the inner if block is skipped but this.cssScale is never reset to undefined. If the element previously had a non-zero width with a CSS scale applied, and later gets offsetWidth = 0 (e.g., width: 0 or temporarily display: none then back), the stale cssScale persists and would incorrectly influence treeScale for descendant layout animations.

The fix is to reset cssScale when dimensions are zero:

Suggested change
if (offsetWidth && offsetHeight) {
const { measuredBox } = this.layout
const measuredWidth =
measuredBox.x.max - measuredBox.x.min
const measuredHeight =
measuredBox.y.max - measuredBox.y.min
const scaleX = measuredWidth / offsetWidth
const scaleY = measuredHeight / offsetHeight
if (
Math.abs(scaleX - 1) > 0.0001 ||
Math.abs(scaleY - 1) > 0.0001
) {
this.cssScale = { x: scaleX, y: scaleY }
} else {
this.cssScale = undefined
}
if (offsetWidth && offsetHeight) {
const { measuredBox } = this.layout
const measuredWidth =
measuredBox.x.max - measuredBox.x.min
const measuredHeight =
measuredBox.y.max - measuredBox.y.min
const scaleX = measuredWidth / offsetWidth
const scaleY = measuredHeight / offsetHeight
if (
Math.abs(scaleX - 1) > 0.0001 ||
Math.abs(scaleY - 1) > 0.0001
) {
this.cssScale = { x: scaleX, y: scaleY }
} else {
this.cssScale = undefined
}
} else {
this.cssScale = undefined
}

@mattgperry mattgperry force-pushed the worktree-fix-issue-3356 branch from 47c5e60 to 6ee0c50 Compare March 9, 2026 14:05
Layout animations in scaled parents (e.g. style={{ scale: 2 }}) require
originX/originY as motion values — not CSS transformOrigin — so that the
projection system's removeTransform uses the correct origin. Added Cypress
E2E test demonstrating the correct usage pattern.

Fixes #3356

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mattgperry mattgperry force-pushed the worktree-fix-issue-3356 branch from 507bc0f to 23b6670 Compare March 9, 2026 15:00
mattgperry and others added 2 commits March 11, 2026 21:06
When a parent element has a CSS transform: scale() string (not motion values),
the projection system was blind to the scale, causing incorrect layout animation
positions. The fix parses CSS transform strings from props to extract scale and
origin values, then incorporates them into resetTransform, removeTransform,
applyTransform, applyTransformsToTarget, and treeScale calculations.

Fixes #3356

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a parent element has a scale transform and layoutRoot, the projection
system's removeTransform() was double-removing parent transforms. During
the update cycle, resetTransformStyle removes transforms from the DOM for
measurement, but removeTransform then tried to mathematically undo
transforms that were already gone, causing over-corrected layout boxes.

The fix adds a wasTransformReset flag that tracks when a node's DOM
transform was reset. removeTransform skips nodes with this flag set,
since their transforms are already absent from measurements. Also
includes CSS transform scale values in treeScale so that
buildProjectionTransform correctly adjusts translations for parents
with CSS transform: scale() strings.

Fixes #3356

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mattgperry mattgperry closed this Mar 12, 2026
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 misaligned in scaled parent containers

1 participant