diff --git a/dev/react/src/tests/layout-shared-sticky.tsx b/dev/react/src/tests/layout-shared-sticky.tsx new file mode 100644 index 0000000000..0cb87c615c --- /dev/null +++ b/dev/react/src/tests/layout-shared-sticky.tsx @@ -0,0 +1,106 @@ +import { motion } from "framer-motion" +import { useRef, useState } from "react" + +/** + * Reproduction for #2941: layoutId transitions have wrong starting position + * when elements are inside a sticky container with a top offset. + */ + +const MenuButton = ({ + label, + active, + onClick, + id, + indicatorRef, +}: { + label: string + active: boolean + onClick: () => void + id: string + indicatorRef: React.RefObject +}) => { + return ( + + ) +} + +export const App = () => { + const [active, setActive] = useState(1) + const indicatorRef = useRef(null) + + return ( +
+
+
+ {[1, 2, 3, 4].map((v) => ( + setActive(v)} + indicatorRef={indicatorRef} + /> + ))} +
+
+
+
+ ) +} diff --git a/packages/framer-motion/cypress/integration/layout-shared-sticky.ts b/packages/framer-motion/cypress/integration/layout-shared-sticky.ts new file mode 100644 index 0000000000..9924ac7414 --- /dev/null +++ b/packages/framer-motion/cypress/integration/layout-shared-sticky.ts @@ -0,0 +1,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})` + ) + }) + }) +}) diff --git a/packages/motion-dom/src/projection/node/HTMLProjectionNode.ts b/packages/motion-dom/src/projection/node/HTMLProjectionNode.ts index 65156609d6..f8b20e60e3 100644 --- a/packages/motion-dom/src/projection/node/HTMLProjectionNode.ts +++ b/packages/motion-dom/src/projection/node/HTMLProjectionNode.ts @@ -25,4 +25,12 @@ export const HTMLProjectionNode = createProjectionNode({ }, checkIsScrollRoot: (instance) => Boolean(window.getComputedStyle(instance).position === "fixed"), + hasStickyAncestor: (instance) => { + let el = instance.parentElement + while (el) { + if (window.getComputedStyle(el).position === "sticky") return true + el = el.parentElement + } + return false + }, }) diff --git a/packages/motion-dom/src/projection/node/create-projection-node.ts b/packages/motion-dom/src/projection/node/create-projection-node.ts index 0a7dd5bf8b..842fc93ff2 100644 --- a/packages/motion-dom/src/projection/node/create-projection-node.ts +++ b/packages/motion-dom/src/projection/node/create-projection-node.ts @@ -137,6 +137,7 @@ export function createProjectionNode({ defaultParent, measureScroll, checkIsScrollRoot, + hasStickyAncestor, resetTransform, }: ProjectionNodeConfig) { return class ProjectionNode implements IProjectionNode { @@ -1021,7 +1022,11 @@ export function createProjectionNode({ const box = visualElement.measureViewportBox() const wasInScrollRoot = - this.scroll?.wasRoot || this.path.some(checkNodeWasScrollRoot) + this.scroll?.wasRoot || + this.path.some(checkNodeWasScrollRoot) || + (hasStickyAncestor && + this.instance && + hasStickyAncestor(this.instance)) if (!wasInScrollRoot) { // Remove viewport scroll to give page-relative coordinates diff --git a/packages/motion-dom/src/projection/node/types.ts b/packages/motion-dom/src/projection/node/types.ts index a90cb9dde1..32d88ee35e 100644 --- a/packages/motion-dom/src/projection/node/types.ts +++ b/packages/motion-dom/src/projection/node/types.ts @@ -160,6 +160,7 @@ export interface ProjectionNodeConfig { ) => VoidFunction measureScroll: (instance: I) => Point checkIsScrollRoot: (instance: I) => boolean + hasStickyAncestor?: (instance: I) => boolean resetTransform?: (instance: I, value?: string) => void }