diff --git a/dev/react/src/tests/scroll-range-with-offset.tsx b/dev/react/src/tests/scroll-range-with-offset.tsx new file mode 100644 index 0000000000..a9cc21399e --- /dev/null +++ b/dev/react/src/tests/scroll-range-with-offset.tsx @@ -0,0 +1,38 @@ +import { animate, scroll } from "framer-motion" +import * as React from "react" +import { useEffect } from "react" + +export const App = () => { + useEffect(() => { + const box = document.getElementById("box")! + + const animation = animate(box, { + opacity: [0, 0.5], + }) + + const stop = scroll(animation, { + rangeStart: "0%", + rangeEnd: "20%", + offset: ["0%", "20%"], + }) + + return () => stop() + }, []) + + return ( + <> +
+ + > + ) +} diff --git a/dev/react/src/tests/scroll-range.tsx b/dev/react/src/tests/scroll-range.tsx new file mode 100644 index 0000000000..44d0a7119e --- /dev/null +++ b/dev/react/src/tests/scroll-range.tsx @@ -0,0 +1,37 @@ +import { animate, scroll } from "framer-motion" +import * as React from "react" +import { useEffect } from "react" + +export const App = () => { + useEffect(() => { + const box = document.getElementById("box")! + + const animation = animate(box, { + opacity: [0, 0.5], + }) + + const stop = scroll(animation, { + rangeStart: "0%", + rangeEnd: "20%", + }) + + return () => stop() + }, []) + + return ( + <> + + + > + ) +} diff --git a/packages/framer-motion/cypress/integration/scroll-range.ts b/packages/framer-motion/cypress/integration/scroll-range.ts new file mode 100644 index 0000000000..f01c3f5c77 --- /dev/null +++ b/packages/framer-motion/cypress/integration/scroll-range.ts @@ -0,0 +1,55 @@ +describe("scroll() rangeStart/rangeEnd", () => { + it("Animation is inactive after rangeEnd", () => { + cy.visit("?test=scroll-range") + .wait(100) + .viewport(100, 400) + + // Scroll to ~50% (well past the 20% rangeEnd) + cy.scrollTo(0, 800) + .wait(200) + .get("#box") + .then(([$element]: any) => { + const opacity = parseFloat( + getComputedStyle($element).opacity + ) + + if ((window as any).ScrollTimeline) { + // With native ScrollTimeline + rangeStart/rangeEnd + fill: auto, + // animation is inactive after 20% scroll. Opacity reverts to + // CSS default (1). + expect(opacity).to.equal(1) + } else { + // Without native ScrollTimeline, rangeStart/rangeEnd have no + // effect. Animation maps full scroll to progress, so at 50% + // scroll opacity = 0.25 (50% of 0 to 0.5). + expect(opacity).to.equal(0.25) + } + }) + }) + + it("Honors offset in the JS fallback when combined with rangeStart/rangeEnd", () => { + cy.visit("?test=scroll-range-with-offset") + .wait(100) + .viewport(100, 400) + + // Scroll to ~50% (well past the 20% rangeEnd / offset end) + cy.scrollTo(0, 800) + .wait(200) + .get("#box") + .then(([$element]: any) => { + const opacity = parseFloat( + getComputedStyle($element).opacity + ) + + if ((window as any).ScrollTimeline) { + // Native path: rangeStart/rangeEnd + fill: "auto" → inactive + // after the range; opacity reverts to CSS default (1). + expect(opacity).to.equal(1) + } else { + // JS fallback path: offset clamps progress to 1 past the + // range, so the animation rests at its end keyframe (0.5). + expect(opacity).to.equal(0.5) + } + }) + }) +}) diff --git a/packages/framer-motion/src/render/dom/scroll/attach-animation.ts b/packages/framer-motion/src/render/dom/scroll/attach-animation.ts index d2a2ab0b85..d4e140bb1a 100644 --- a/packages/framer-motion/src/render/dom/scroll/attach-animation.ts +++ b/packages/framer-motion/src/render/dom/scroll/attach-animation.ts @@ -10,6 +10,8 @@ export function attachToAnimation( ) { const timeline = getTimeline(options) + const hasUserRange = options.rangeStart || options.rangeEnd + const range = options.target ? offsetToViewTimelineRange(options.offset) : undefined @@ -26,11 +28,17 @@ export function attachToAnimation( return animation.attachTimeline({ timeline: useNative ? timeline : undefined, - ...(range && - useNative && { - rangeStart: range.rangeStart, - rangeEnd: range.rangeEnd, - }), + ...(hasUserRange + ? { + rangeStart: options.rangeStart, + rangeEnd: options.rangeEnd, + fill: "auto", + } + : range && + useNative && { + rangeStart: range.rangeStart, + rangeEnd: range.rangeEnd, + }), observe: (valueAnimation) => { valueAnimation.pause() diff --git a/packages/framer-motion/src/render/dom/scroll/types.ts b/packages/framer-motion/src/render/dom/scroll/types.ts index f9c9a08b6f..dea4d416bf 100644 --- a/packages/framer-motion/src/render/dom/scroll/types.ts +++ b/packages/framer-motion/src/render/dom/scroll/types.ts @@ -6,6 +6,8 @@ export interface ScrollOptions { target?: Element axis?: "x" | "y" offset?: ScrollOffset + rangeStart?: string + rangeEnd?: string } export interface ScrollOptionsWithDefaults extends ScrollOptions { diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index 93f8201918..ae853b65c2 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -250,6 +250,7 @@ export class NativeAnimation