Skip to content

Fix wrong starting position for layout transitions in sticky containers#3657

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

Fix wrong starting position for layout transitions in sticky containers#3657
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-2941

Conversation

@mattgperry
Copy link
Collaborator

Summary

  • Fixes incorrect layout animation starting positions when layoutId elements are inside position: sticky containers
  • The projection system's measurePageBox() was adding root scroll offset to viewport coordinates for elements inside sticky containers. Since sticky elements maintain viewport-relative positions when stuck, this produced incorrect page-relative coordinates, offsetting the animation start by the scroll amount
  • Adds a hasStickyAncestor check that walks up the DOM to detect sticky positioning and skips the scroll offset when found

Fixes #2941

Test plan

  • Added Cypress E2E test (layout-shared-sticky) that verifies layout animations work correctly inside sticky containers after scrolling
  • Cypress test passes on React 18 and React 19
  • All 776 unit tests pass
  • yarn build succeeds

Note: The full bug (animation starting hundreds of pixels off) is most visible in Chrome/Edge/Firefox with real user scrolling. The Cypress/Electron environment partially reproduces the offset. The test validates that the indicator stays within the button area during animation, not offset by the scroll amount.

🤖 Generated with Claude Code

…ainers

When elements with layoutId are inside a position: sticky container,
measurePageBox() incorrectly adds root scroll offset to viewport
coordinates. Since sticky elements maintain viewport-relative positions
when stuck, adding scroll offset produces incorrect page-relative
coordinates, causing layout animation starting positions to be offset
by the scroll amount.

Add hasStickyAncestor check to measurePageBox() that walks up the DOM
ancestor chain to detect sticky positioning. When a sticky ancestor is
found, skip adding root scroll offset, keeping measurements in viewport
coordinates where both snapshot and current layout are consistent.

Fixes #2941

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

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR fixes a long-standing bug (#2941) where layoutId transitions produced incorrect starting positions for elements inside position: sticky containers after the page had been scrolled. The root cause was measurePageBox() unconditionally adding the root scroll offset to viewport coordinates, even for sticky elements whose viewport position doesn't change with scroll.

The fix introduces a hasStickyAncestor config hook that walks the DOM ancestor chain and, when a sticky ancestor is found, treats the node the same as a scroll-root (skipping the scroll-offset addition). The approach is architecturally clean and backward-compatible.

Key concerns:

  • Sticky-not-engaged regression (P1): hasStickyAncestor returns true based solely on the CSS position property — it does not check whether the sticky ancestor is currently stuck to the viewport. When the page has been scrolled but not past the sticky threshold (e.g., top: 200px, scroll at 100 px), the fix incorrectly skips the scroll offset. The animation will start scrollY pixels too high instead of correcting the bug. The existing test only exercises the fully-engaged state and does not catch this regression.

  • Scroll-container boundary not respected (P1): The ancestor walk does not stop at an overflow: scroll/auto element. A sticky element positioned relative to an inner scroll container (not the root) would still cause the root window scroll offset to be skipped, which is incorrect in that layout.

  • getComputedStyle cost (P2): The function calls window.getComputedStyle() for every DOM ancestor on every measurePageBox() invocation, including calls made inside removeTransform() for each transformed ancestor. For deep trees this may add measurable cost at animation-start time; caching the result per projection node would mitigate it.

Confidence Score: 2/5

  • The fix correctly resolves the primary bug scenario but introduces a correctness regression for sticky-not-yet-engaged states and does not handle sticky inside inner scroll containers.
  • The implementation in hasStickyAncestor is too broad: it fires for any element with a sticky CSS ancestor regardless of whether that sticky is currently active. This produces a new class of wrong animation positions (offset by -scrollY) in the non-engaged case, and the existing tests do not cover it. The scroll-container boundary issue is an additional correctness gap. These are P1 logic issues that warrant fixing before merge.
  • packages/motion-dom/src/projection/node/HTMLProjectionNode.ts — the hasStickyAncestor implementation needs engagement detection and scroll-container boundary awareness.

Important Files Changed

Filename Overview
packages/motion-dom/src/projection/node/HTMLProjectionNode.ts Adds hasStickyAncestor — walks the full DOM ancestor chain using getComputedStyle. Does not check whether the sticky ancestor is actually engaged, and does not stop at inner scroll-container boundaries, leading to potential over-application of the fix.
packages/motion-dom/src/projection/node/create-projection-node.ts Wires hasStickyAncestor into the wasInScrollRoot check in measurePageBox(). The integration is clean and backward-compatible (optional config property, guarded with &&). Correctness depends entirely on hasStickyAncestor's implementation.
packages/motion-dom/src/projection/node/types.ts Adds optional hasStickyAncestor property to ProjectionNodeConfig. Type change is minimal and non-breaking.
packages/framer-motion/cypress/integration/layout-shared-sticky.ts New Cypress E2E test that validates indicator stays near buttons while animating inside a stuck sticky container. Covers the primary regression scenario but not the pre-engagement or inner-scroll-container edge cases.
dev/react/src/tests/layout-shared-sticky.tsx React test fixture that renders a sticky nav with layoutId-animated indicators. Straightforward and correctly reproduces the bug scenario.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[measurePageBox called] --> B[measureViewportBox - get viewport coords]
    B --> C{wasInScrollRoot?}
    C -- "scroll?.wasRoot OR path has scroll root" --> D[Return viewport coords as-is]
    C -- "new: hasStickyAncestor check" --> E{hasStickyAncestor?}
    E -- "true (any sticky ancestor)" --> D
    E -- "false" --> F[translateAxis: add root scroll offset\nviewport → page coords]
    F --> G[Return page-relative box]
    D --> G

    style E fill:#ffe0b2,stroke:#f57c00
    style D fill:#c8e6c9,stroke:#388e3c
    style F fill:#e3f2fd,stroke:#1976d2
Loading

Last reviewed commit: 21301dd

Comment on lines +28 to +35
hasStickyAncestor: (instance) => {
let el = instance.parentElement
while (el) {
if (window.getComputedStyle(el).position === "sticky") return true
el = el.parentElement
}
return false
},
Copy link

Choose a reason for hiding this comment

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

P1 Sticky-not-yet-engaged case skips scroll offset incorrectly

hasStickyAncestor returns true purely based on the CSS position property — it does not check whether the sticky ancestor is currently in its "stuck" (engaged) state. This means that when a page has been scrolled but not enough for the sticky container to engage, the scroll offset compensation is still skipped.

In this scenario the element's getBoundingClientRect().y equals naturalPageY - scrollY (normal-flow behaviour). Skipping translateAxis(box.y, scrollY) makes measurePageBox() return naturalPageY - scrollY instead of the correct naturalPageY, so the animation starts scrollY pixels too high.

To detect whether a sticky ancestor is actually engaged, you could compare the ancestor's getBoundingClientRect().top against its top CSS value:

hasStickyAncestor: (instance) => {
    let el = instance.parentElement
    while (el) {
        const style = window.getComputedStyle(el)
        if (style.position === "sticky") {
            const stickyTop = parseFloat(style.top) || 0
            // If the element is at (or near) its `top` value, it is engaged
            if (el.getBoundingClientRect().top <= stickyTop + 1) return true
        }
        el = el.parentElement
    }
    return false
},

The existing test only exercises the fully-engaged state (scroll to 2 000 px), so this case is not currently covered.

Comment on lines +28 to +35
hasStickyAncestor: (instance) => {
let el = instance.parentElement
while (el) {
if (window.getComputedStyle(el).position === "sticky") return true
el = el.parentElement
}
return false
},
Copy link

Choose a reason for hiding this comment

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

P1 Walk does not stop at a scroll-container boundary

position: sticky is resolved relative to the nearest scrollable ancestor, not necessarily the root viewport. If the sticky container is inside an overflow: scroll/auto element, its "stuck" position is relative to that inner container, not the window. In that case the code skips this.root.scroll.offset (the window-level scroll) even though the correction should still be applied for the inner-container case.

Consider stopping the ancestor walk when an element with overflow: scroll or overflow: auto is encountered — that scroll container is the sticky's actual reference frame and the root scroll offset would still be relevant.

hasStickyAncestor: (instance) => {
    let el = instance.parentElement
    while (el) {
        const style = window.getComputedStyle(el)
        const overflow = style.overflowY
        // Stop at a scroll container — sticky is relative to it, not the root
        if (overflow === "scroll" || overflow === "auto") break
        if (style.position === "sticky") return true
        el = el.parentElement
    }
    return false
},

Comment on lines +28 to +35
hasStickyAncestor: (instance) => {
let el = instance.parentElement
while (el) {
if (window.getComputedStyle(el).position === "sticky") return true
el = el.parentElement
}
return false
},
Copy link

Choose a reason for hiding this comment

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

P2 getComputedStyle called for every ancestor on each measurement

window.getComputedStyle() forces a style recalculation and is relatively expensive. The function is called for every DOM ancestor in the tree on every measurePageBox() invocation — which also occurs inside removeTransform() for each ancestor node that has a transform (line 1124 of create-projection-node.ts).

For layouts with deep DOM hierarchies or many transformed ancestors this could accumulate meaningful cost at animation-start time. Consider caching the result on the projection node (invalidated on mount/unmount) so the walk is done at most once per animation batch rather than per-measurement.

Comment on lines +1 to +60
describe("Shared layout: sticky container", () => {
it("Layout animation works inside a sticky container after scrolling", () => {
cy.visit("?test=layout-shared-sticky")
.wait(50)
// Click btn-2 so indicator moves there
.get("#btn-2")
.trigger("click")
.wait(300)
// Scroll down so sticky container kicks in
.window()
.then((win: any) => {
win.scrollTo(0, 2000)
})
.wait(100)
// Click btn-3 to trigger layoutId animation while sticky
.get("#btn-3")
.trigger("click")
.wait(500)
.get("#indicator")
.then(([$indicator]: any) => {
const appDoc = $indicator.ownerDocument
const btn2 = (
appDoc.querySelector("#btn-2") as HTMLElement
).getBoundingClientRect()
const btn3 = (
appDoc.querySelector("#btn-3") as HTMLElement
).getBoundingClientRect()
const indicator = $indicator.getBoundingClientRect()
const scrollY =
appDoc.defaultView.scrollY ||
appDoc.defaultView.pageYOffset

// The indicator is animating from btn-2 to btn-3 (10s linear).
// At 500ms (~5% progress), it should be between the two buttons.
//
// WITHOUT the fix, the starting position is offset by ~scrollY
// (2000px), so the indicator would be far outside the button range.
//
// WITH the fix, the indicator stays within the button area.
const buttonsMinTop = Math.min(btn2.top, btn3.top)
const buttonsMaxTop = Math.max(btn2.top, btn3.top)

// Indicator must be within the button range (with tolerance
// for animation overshoot). The key assertion: it must NOT
// be offset by the scroll amount.
expect(indicator.top).to.be.greaterThan(
buttonsMinTop - 50,
`Indicator (${indicator.top}) should be near buttons ` +
`(${buttonsMinTop}-${buttonsMaxTop}), ` +
`not offset by scroll (${scrollY})`
)
expect(indicator.top).to.be.lessThan(
buttonsMaxTop + 50,
`Indicator (${indicator.top}) should be near buttons ` +
`(${buttonsMinTop}-${buttonsMaxTop}), ` +
`not offset by scroll (${scrollY})`
)
})
})
})
Copy link

Choose a reason for hiding this comment

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

P2 Test only covers the fully-engaged sticky state

The Cypress test scrolls to 2 000 px (far past the sticky threshold of top: 20) and then triggers the animation. This validates the primary bug scenario but leaves two important cases untested:

  1. Sticky not yet engaged with page scroll > 0 — e.g., top: 200, scroll to 100 px, then animate. With the current implementation hasStickyAncestor still returns true and the scroll offset is skipped, producing an animation starting position offset by -100 px.
  2. Sticky inside an inner scroll container — the root scroll offset should still be applied.

Adding a test variant at scrollY < sticky.top (before engagement) would catch the regression described in the hasStickyAncestor comments above.

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] Wrong starting position for layout transitions and sticky position

1 participant