diff --git a/dev/react/src/tests/scroll-target-translate.tsx b/dev/react/src/tests/scroll-target-translate.tsx new file mode 100644 index 0000000000..b8dde198bf --- /dev/null +++ b/dev/react/src/tests/scroll-target-translate.tsx @@ -0,0 +1,42 @@ +import { motion, useScroll, useTransform } from "framer-motion" +import * as React from "react" +import { useRef } from "react" + +/** + * Regression test for #2914: useScroll target should account for CSS translate. + * + * The target div has transform: translateY(500px), pushing it 500px lower visually. + * Without the fix, useScroll ignores the translate and reports incorrect progress. + */ +export const App = () => { + const targetRef = useRef(null) + const { scrollYProgress } = useScroll({ + target: targetRef, + offset: ["start end", "end start"], + }) + + // Drive opacity from scroll progress so Cypress can read computed style + const opacity = useTransform(scrollYProgress, [0, 1], [1, 0]) + + return ( +
+
+
+ +
+
+
+ ) +} diff --git a/packages/framer-motion/cypress/integration/scroll-target-translate.ts b/packages/framer-motion/cypress/integration/scroll-target-translate.ts new file mode 100644 index 0000000000..22447b6d09 --- /dev/null +++ b/packages/framer-motion/cypress/integration/scroll-target-translate.ts @@ -0,0 +1,26 @@ +describe("useScroll target accounts for CSS translate (#2914)", () => { + it("scroll progress reflects CSS translateY on target", () => { + cy.visit("?test=scroll-target-translate") + .wait(200) + .scrollTo(0, 1000, { duration: 0 }) + .wait(500) + .get("#indicator") + .then(([$el]: any) => { + const opacity = parseFloat(getComputedStyle($el).opacity) + /** + * Target layout position: 1000px (spacer height). + * Target has transform: translateY(500px), visual position = 1500px. + * With offset ["start end", "end start"] and 660px viewport: + * + * With fix (accounts for translate): + * progress at scroll 1000 ≈ 0.19, opacity ≈ 0.81 + * + * Without fix (ignores translate): + * progress at scroll 1000 ≈ 0.77, opacity ≈ 0.23 + * + * Assert opacity > 0.5 to verify translate is accounted for. + */ + expect(opacity).to.be.greaterThan(0.5) + }) + }) +}) diff --git a/packages/framer-motion/src/render/dom/scroll/offsets/inset.ts b/packages/framer-motion/src/render/dom/scroll/offsets/inset.ts index 4ddef6dd38..8a5bbb87d1 100644 --- a/packages/framer-motion/src/render/dom/scroll/offsets/inset.ts +++ b/packages/framer-motion/src/render/dom/scroll/offsets/inset.ts @@ -1,5 +1,28 @@ import { isHTMLElement } from "motion-dom" +function addTranslateOffset( + inset: { x: number; y: number }, + element: HTMLElement +) { + const style = getComputedStyle(element) + const { translate, transform } = style + + if (translate && translate !== "none") { + const parts = translate.split(" ") + inset.x += parseFloat(parts[0]) || 0 + inset.y += parseFloat(parts[1] || "0") || 0 + } + + if (transform && transform !== "none") { + const match = transform.match(/matrix\(([^)]+)\)/) + if (match) { + const values = match[1].split(",") + inset.x += parseFloat(values[4]) + inset.y += parseFloat(values[5]) + } + } +} + export function calcInset(element: Element, container: Element) { const inset = { x: 0, y: 0 } @@ -8,6 +31,7 @@ export function calcInset(element: Element, container: Element) { if (isHTMLElement(current)) { inset.x += current.offsetLeft inset.y += current.offsetTop + addTranslateOffset(inset, current) current = current.offsetParent } else if (current.tagName === "svg") { /**