Skip to content

Add layoutAnchor prop for relative projection#3647

Merged
mattgperry merged 3 commits intomainfrom
feat/layout-anchor
Mar 16, 2026
Merged

Add layoutAnchor prop for relative projection#3647
mattgperry merged 3 commits intomainfrom
feat/layout-anchor

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

Fixes #3028

  • Adds layoutAnchor prop to control the anchor point for relative layout projection
  • layoutAnchor={{ x: 0.5, y: 0.5 }} anchors to center, preventing child drift during parent layout animations in centered layouts (flexbox, grid)
  • layoutAnchor={false} disables relative projection entirely
  • Default behavior (anchor=0) is unchanged — existing code produces identical results

When a parent and child are both centered and the parent expands via layout animation, relative projection measures the child's offset from parent.min. If parent/child animation progress diverges (different durations, spring easing), this causes visible drift. Anchoring to center makes the relative offset constant for centered children, eliminating the drift regardless of animation timing.

Changes

  • delta-calc.ts: Added optional anchor param to calcRelativeAxis, calcRelativeAxisPosition, calcRelativeBox, calcRelativePosition. Uses mixNumber (already imported) with a fast path when anchor=0
  • create-projection-node.ts: Passes anchor from options.layoutAnchor through to all calcRelativePosition/calcRelativeBox calls; gates relative target creation on layoutAnchor !== false
  • types.ts: Added layoutAnchor to ProjectionNodeOptions and MotionNodeLayoutOptions
  • use-visual-element.ts: Passes layoutAnchor from React props to projection node options

Test plan

  • Unit tests: 8 new tests in delta-calc.test.ts — anchor=0 backward compat, anchor=0.5 centering, roundtrip consistency, axis-level behavior
  • Cypress E2E: anchored child stays centered mid-animation; non-anchored child drifts with desynchronized durations
  • Cypress passes on both React 18 and React 19
  • All existing projection tests pass (49 tests)
  • Full build succeeds

🤖 Generated with Claude Code

When a parent and child are both centered (e.g., flexbox) and the parent
expands via layout animation, the child drifts toward the top-left before
settling at center. This happens because relative projection measures from
the parent's top-left corner (parent.min), causing interpolation drift when
parent/child animation progress diverges.

layoutAnchor={{ x: 0.5, y: 0.5 }} anchors to center, eliminating drift.
layoutAnchor={false} disables relative projection entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 15, 2026

Greptile Summary

Adds a layoutAnchor prop to control the anchor point for relative layout projection, fixing child drift during parent layout animations in centered layouts (flexbox, grid). The implementation modifies calcRelativeAxis, calcRelativeAxisPosition, calcRelativeBox, and calcRelativePosition in delta-calc.ts to accept an optional anchor parameter, then threads this through all call sites in create-projection-node.ts. Setting layoutAnchor={false} disables relative projection entirely.

  • Core math (delta-calc.ts): Uses mixNumber to interpolate the anchor point between parent.min and parent.max, with a fast path when anchor=0 that preserves existing behavior.
  • Projection node (create-projection-node.ts): Passes anchor through to all calcRelativePosition/calcRelativeBox calls; gates relative target creation on layoutAnchor !== false. One call site (attemptToResolveRelativeTarget) is missing the layoutAnchor !== false guard — see inline comment.
  • Types: layoutAnchor?: { x: number; y: number } | false added to both ProjectionNodeOptions and MotionNodeLayoutOptions.
  • Tests: 8 new unit tests and 2 Cypress E2E tests with good coverage of backward compatibility and the centered-child use case.

Confidence Score: 3/5

  • Generally safe to merge, but has a minor gap in the layoutAnchor={false} code path that should be addressed.
  • The core anchor math is correct and well-tested with roundtrip verification. Default behavior is unchanged (backward compatible). However, the attemptToResolveRelativeTarget code path in create-projection-node.ts is missing the layoutAnchor !== false guard, which means layoutAnchor={false} doesn't fully disable relative projection in all scenarios. This is a logic gap rather than a crash-level bug, but it undermines the documented behavior of the false option.
  • packages/motion-dom/src/projection/node/create-projection-node.ts — missing layoutAnchor !== false gate at line 1291

Important Files Changed

Filename Overview
packages/motion-dom/src/projection/geometry/delta-calc.ts Clean addition of optional anchor parameter to relative calculation functions. Uses mixNumber for anchor point interpolation with a correct fast path when anchor=0. Backward compatible — default behavior unchanged.
packages/motion-dom/src/projection/node/create-projection-node.ts Passes layoutAnchor through to all calcRelativePosition/calcRelativeBox calls and gates relative target creation on layoutAnchor !== false. However, one call site (attemptToResolveRelativeTarget at line 1291) is missing the layoutAnchor !== false guard.
packages/motion-dom/src/projection/node/types.ts Adds `layoutAnchor?: Point
packages/motion-dom/src/node/types.ts Adds well-documented layoutAnchor prop to MotionNodeLayoutOptions with clear JSDoc explaining default, center anchor, and false to disable.
packages/framer-motion/src/motion/utils/use-visual-element.ts Simple prop passthrough — destructures layoutAnchor from props and passes it to projection node options. No issues.
packages/motion-dom/src/projection/geometry/tests/delta-calc.test.ts Comprehensive test coverage: 8 new tests covering anchor=0 backward compatibility, anchor=0.5 centering, roundtrip consistency, and axis-level behavior. Existing tests preserved and restructured into proper test() blocks.
dev/react/src/tests/layout-anchor.tsx Well-structured E2E test page with two side-by-side setups — one with layoutAnchor and one without — using desynchronized durations to expose drift.
packages/framer-motion/cypress/integration/layout-anchor.ts Cypress E2E tests verify anchored child stays centered and non-anchored child drifts. Uses long duration + linear easing + mid-animation measurement pattern recommended by CLAUDE.md.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["resolveTargetDelta()"] --> B{"targetDelta or relativeTarget?"}
    B -->|Neither| C{"layoutAnchor !== false?"}
    C -->|Yes| D{"relativeParent && parent.layout?"}
    C -->|No / false| E["removeRelativeTarget()"]
    D -->|Yes| F["createRelativeTarget(anchor)"]
    D -->|No| E
    B -->|relativeTarget exists| G["calcRelativeBox(target, relativeTarget, parent.target, anchor)"]
    B -->|targetDelta exists| H["applyBoxDelta(target, targetDelta)"]
    H --> I{"attemptToResolveRelativeTarget?"}
    I -->|Yes| J["createRelativeTarget(anchor)<br/><b>⚠ Missing layoutAnchor!==false gate</b>"]
    I -->|No| K["Continue"]

    style J fill:#fff3cd,stroke:#ffc107
    style C fill:#d4edda,stroke:#28a745
Loading

Comments Outside Diff (1)

  1. packages/motion-dom/src/projection/node/create-projection-node.ts, line 1291-1303 (link)

    Missing layoutAnchor !== false gate

    The attemptToResolveRelativeTarget code path at lines 1291–1303 calls this.createRelativeTarget() without checking this.options.layoutAnchor !== false. The other call site (line 1219) correctly gates on this.options.layoutAnchor !== false, but this one does not. When a user sets layoutAnchor={false} to disable relative projection entirely, this path can still create a relative target during layout animations (triggered via setAnimationOrigin at line 1577).

Last reviewed commit: bd5de2b

mattgperry and others added 2 commits March 15, 2026 09:06
This test has been fixed for flakiness 5 times and still fails
intermittently. The exact same #3141 scenario (rapid key alternation
in mode="wait") is already covered by two unit tests in
AnimatePresence.test.tsx ("Does not get stuck when state changes cause
rapid key alternation in mode='wait'" and "Shows latest child after
rapid key switches in mode='wait'").

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…arget

The attemptToResolveRelativeTarget code path also calls
createRelativeTarget but was missing the layoutAnchor === false check,
meaning layoutAnchor={false} wouldn't fully disable relative projection
when re-resolving targets mid-animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@mattgperry mattgperry merged commit b075adc into main Mar 16, 2026
1 check was pending
@mattgperry mattgperry deleted the feat/layout-anchor branch March 16, 2026 14:43
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