diff --git a/dev/react/src/tests/scroll-range.tsx b/dev/react/src/tests/scroll-range.tsx new file mode 100644 index 0000000000..2d2f6859b4 --- /dev/null +++ b/dev/react/src/tests/scroll-range.tsx @@ -0,0 +1,48 @@ +import { animate, scroll } from "framer-motion" +import * as React from "react" +import { useEffect } from "react" + +/** + * Reproduction for #3001: scroll() with rangeStart/rangeEnd should deactivate + * the animation outside the range, so the element's base CSS (here opacity 0.1, + * which a :hover etc. could also provide) applies again past rangeEnd — matching + * native `animation-range`. + */ +export const App = () => { + useEffect(() => { + const animation = animate("#box", { opacity: [0, 1] }, { ease: "linear" }) + + const stop = scroll(animation, { rangeStart: "0%", rangeEnd: "20%" }) + + return () => stop() + }, []) + + const nativeTimeline = + typeof window !== "undefined" && "ScrollTimeline" in window + + return ( + <> + +
+ {nativeTimeline ? "native" : "fallback"} +
+
+
+
+
+
+
+ + ) +} + +const spacer: React.CSSProperties = { height: "100vh" } + +const box: React.CSSProperties = { + position: "fixed", + top: 0, + left: 0, + width: 100, + height: 100, + backgroundColor: "red", +} 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..99b73a7e31 --- /dev/null +++ b/packages/framer-motion/cypress/integration/scroll-range.ts @@ -0,0 +1,42 @@ +/** + * #3001: scroll() rangeStart/rangeEnd should deactivate the animation outside + * the range, restoring the element's base CSS opacity (0.1) past rangeEnd. + * + * Page height = 5 * 100vh, so with a 1000px viewport scrollLength = 4000px. + * rangeEnd "20%" = 800px. + */ +describe("scroll() rangeStart/rangeEnd (#3001)", () => { + it("Animates within the range and deactivates past rangeEnd", () => { + cy.viewport(1000, 1000) + cy.visit("?test=scroll-range").wait(200) + + // 600px scroll = 15% (three quarters through the 0%–20% range) → ~0.75, + // clearly distinct from the 0.1 base. + cy.scrollTo(0, 600) + .wait(200) + .get("#box") + .should(([$el]: any) => { + const opacity = parseFloat(getComputedStyle($el).opacity) + expect(opacity).to.be.within(0.65, 0.85) + }) + + // 2000px scroll = 50%, past rangeEnd (20%) → animation inactive, so the + // base CSS opacity (0.1) applies again. + cy.scrollTo(0, 2000) + .wait(200) + .get("#box") + .should(([$el]: any) => { + const opacity = parseFloat(getComputedStyle($el).opacity) + expect(opacity).to.be.closeTo(0.1, 0.03) + }) + + // Scrolling back into the range reactivates the animation. + cy.scrollTo(0, 600) + .wait(200) + .get("#box") + .should(([$el]: any) => { + const opacity = parseFloat(getComputedStyle($el).opacity) + expect(opacity).to.be.within(0.65, 0.85) + }) + }) +}) diff --git a/packages/framer-motion/src/render/dom/scroll/__tests__/range.test.ts b/packages/framer-motion/src/render/dom/scroll/__tests__/range.test.ts new file mode 100644 index 0000000000..99bee0288e --- /dev/null +++ b/packages/framer-motion/src/render/dom/scroll/__tests__/range.test.ts @@ -0,0 +1,136 @@ +import { frame } from "motion-dom" +import { animate } from "../../../../animation/animate" +import { scroll } from "../" + +// Mock scrollingElement for testing +Object.defineProperty(document, "scrollingElement", { + value: document.documentElement, + writable: false, + configurable: true, +}) + +const measurements = new Map>() + +const createMockMeasurement = (element: Element, name: string) => { + const elementMeasurements = measurements.get(element) || {} + measurements.set(element, elementMeasurements) + + if (!element.hasOwnProperty(name)) { + Object.defineProperty(element, name, { + get: () => elementMeasurements[name] ?? 0, + set: () => {}, + }) + } + + return (value: number) => { + elementMeasurements[name] = value + } +} + +const setWindowHeight = createMockMeasurement( + document.scrollingElement!, + "clientHeight" +) +const setDocumentHeight = createMockMeasurement( + document.scrollingElement!, + "scrollHeight" +) +const setScrollTop = createMockMeasurement( + document.scrollingElement!, + "scrollTop" +) + +async function nextFrame() { + return new Promise((resolve) => { + window.dispatchEvent(new window.Event("scroll")) + frame.postRender(() => resolve()) + }) +} + +async function fireScroll(distance: number) { + setScrollTop(distance) + window.dispatchEvent(new window.Event("scroll")) + return nextFrame() +} + +/** + * scrollLength = scrollHeight (3000) - clientHeight (1000) = 2000, so a scroll + * distance maps to raw scroll progress of `distance / 2000`. + */ +describe("scroll() rangeStart/rangeEnd (#3001)", () => { + beforeEach(async () => { + setWindowHeight(1000) + setDocumentHeight(3000) + await fireScroll(0) + }) + + test("JS animation maps to and deactivates outside the range", async () => { + const box = document.createElement("div") + document.body.appendChild(box) + + const animation = animate( + box, + { opacity: [0, 1] }, + { duration: 1, ease: "linear" } + ) + + // Let keyframes resolve and the timeline attach. + await nextFrame() + await nextFrame() + + const stop = scroll(animation, { rangeStart: "0%", rangeEnd: "50%" }) + + // 25% scroll is halfway through the 0%–50% range → opacity 0.5. + await fireScroll(500) + await nextFrame() + expect(parseFloat(box.style.opacity)).toBeCloseTo(0.5, 2) + + // Past rangeEnd (50%) the animation deactivates: its inline style is + // removed so the CSS cascade (e.g. :hover) can take over. + await fireScroll(1500) + await nextFrame() + expect(box.style.opacity).toBe("") + + // Scrolling back into the range reactivates the animation. + await fireScroll(500) + await nextFrame() + expect(parseFloat(box.style.opacity)).toBeCloseTo(0.5, 2) + + stop() + box.remove() + }) + + test("JS animation is inactive before a non-zero rangeStart", async () => { + const box = document.createElement("div") + document.body.appendChild(box) + + const animation = animate( + box, + { opacity: [0, 1] }, + { duration: 1, ease: "linear" } + ) + + await nextFrame() + await nextFrame() + + const stop = scroll(animation, { rangeStart: "25%", rangeEnd: "75%" }) + + // 10% scroll is before rangeStart (25%) → inactive. + await fireScroll(200) + await nextFrame() + expect(box.style.opacity).toBe("") + + // 50% scroll is halfway through the 25%–75% range → opacity 0.5. + await fireScroll(1000) + await nextFrame() + expect(parseFloat(box.style.opacity)).toBeCloseTo(0.5, 2) + + // 90% scroll is past rangeEnd (75%) → inactive again. + await fireScroll(1800) + await nextFrame() + expect(box.style.opacity).toBe("") + + stop() + box.remove() + }) +}) 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..ffd1eb38f5 100644 --- a/packages/framer-motion/src/render/dom/scroll/attach-animation.ts +++ b/packages/framer-motion/src/render/dom/scroll/attach-animation.ts @@ -1,14 +1,17 @@ import { AnimationPlaybackControls, observeTimeline } from "motion-dom" +import { scrollInfo } from "./track" import { ScrollOptionsWithDefaults } from "./types" import { canUseNativeTimeline } from "./utils/can-use-native-timeline" import { getTimeline } from "./utils/get-timeline" import { offsetToViewTimelineRange } from "./utils/offset-to-range" +import { resolveRangeFraction, resolveRangeString } from "./utils/range" export function attachToAnimation( animation: AnimationPlaybackControls, options: ScrollOptionsWithDefaults ) { - const timeline = getTimeline(options) + const hasUserRange = + options.rangeStart !== undefined || options.rangeEnd !== undefined const range = options.target ? offsetToViewTimelineRange(options.offset) @@ -24,20 +27,73 @@ export function attachToAnimation( ? canUseNativeTimeline(options.target) && !!range : canUseNativeTimeline() + /** + * The JS observe fallback drives range deactivation itself (below), so it + * doesn't need a timeline. Avoid creating an unused scroll tracker for it. + */ + const timeline = + useNative || !hasUserRange ? getTimeline(options) : undefined + + /** + * User-provided rangeStart/rangeEnd take precedence over the offset-derived + * ViewTimeline range. Forward them to the native animation as a WAAPI range + * with `fill: "auto"`, so the effect is removed outside the range (matching + * native `animation-range`, allowing `:hover` and other styles to apply). + */ + const rangeTiming = hasUserRange + ? { + rangeStart: resolveRangeString(options.rangeStart), + rangeEnd: resolveRangeString(options.rangeEnd), + fill: "auto", + } + : range && useNative + ? { rangeStart: range.rangeStart, rangeEnd: range.rangeEnd } + : undefined + + const rangeStartFraction = resolveRangeFraction(options.rangeStart, 0) + const rangeEndFraction = resolveRangeFraction(options.rangeEnd, 1) + const rangeSpan = rangeEndFraction - rangeStartFraction + return animation.attachTimeline({ timeline: useNative ? timeline : undefined, - ...(range && - useNative && { - rangeStart: range.rangeStart, - rangeEnd: range.rangeEnd, - }), + ...rangeTiming, observe: (valueAnimation) => { valueAnimation.pause() + /** + * When the user has set rangeStart/rangeEnd and we've fallen back to + * JS observation (no native ScrollTimeline, or a JS animation), map + * the active window ourselves and deactivate the animation outside + * it so the underlying styles can take over. + */ + if (hasUserRange) { + return scrollInfo((info) => { + const axis = info[options.axis] + const progress = axis.scrollLength + ? axis.current / axis.scrollLength + : 0 + + if ( + progress < rangeStartFraction || + progress > rangeEndFraction + ) { + valueAnimation.setActive?.(false) + return + } + + valueAnimation.setActive?.(true) + valueAnimation.time = + valueAnimation.iterationDuration * + (rangeSpan > 0 + ? (progress - rangeStartFraction) / rangeSpan + : 0) + }, options) + } + return observeTimeline((progress) => { valueAnimation.time = valueAnimation.iterationDuration * progress - }, timeline) + }, timeline!) }, }) } diff --git a/packages/framer-motion/src/render/dom/scroll/types.ts b/packages/framer-motion/src/render/dom/scroll/types.ts index f9c9a08b6f..d3a7172641 100644 --- a/packages/framer-motion/src/render/dom/scroll/types.ts +++ b/packages/framer-motion/src/render/dom/scroll/types.ts @@ -6,6 +6,18 @@ export interface ScrollOptions { target?: Element axis?: "x" | "y" offset?: ScrollOffset + /** + * The scroll position at which the animation becomes active, mirroring the + * native WAAPI `rangeStart` (e.g. `"0%"`) or a `0`–`1` progress fraction. + */ + rangeStart?: string | number + /** + * The scroll position at which the animation becomes inactive, mirroring the + * native WAAPI `rangeEnd` (e.g. `"20%"`) or a `0`–`1` progress fraction. + * Past this point the animation is removed so the CSS cascade (e.g. `:hover`) + * can take over, matching native `animation-range`. + */ + rangeEnd?: string | number } export interface ScrollOptionsWithDefaults extends ScrollOptions { diff --git a/packages/framer-motion/src/render/dom/scroll/utils/range.ts b/packages/framer-motion/src/render/dom/scroll/utils/range.ts new file mode 100644 index 0000000000..308c74f83b --- /dev/null +++ b/packages/framer-motion/src/render/dom/scroll/utils/range.ts @@ -0,0 +1,32 @@ +/** + * Resolve a user-provided `rangeStart`/`rangeEnd` into a 0–1 scroll progress + * fraction, used to drive the JS observe fallback's active window. + * + * Accepts a number (already a 0–1 fraction), a percentage string (`"20%"`) or + * a bare numeric string (`"0.2"`). Anything unparseable (e.g. a named WAAPI + * range like `"cover 50%"`, which only applies to native timelines) falls back + * to the provided default. + */ +export function resolveRangeFraction( + value: string | number | undefined, + fallback: number +): number { + if (value === undefined) return fallback + if (typeof value === "number") return value + + const parsed = parseFloat(value) + if (Number.isNaN(parsed)) return fallback + + return value.trim().endsWith("%") ? parsed / 100 : parsed +} + +/** + * Resolve a user-provided `rangeStart`/`rangeEnd` into a WAAPI-acceptable + * string, converting a 0–1 fraction into a percentage. + */ +export function resolveRangeString( + value: string | number | undefined +): string | undefined { + if (value === undefined) return undefined + return typeof value === "number" ? `${value * 100}%` : value +} diff --git a/packages/motion-dom/src/animation/GroupAnimation.ts b/packages/motion-dom/src/animation/GroupAnimation.ts index d1eaababe8..909b220676 100644 --- a/packages/motion-dom/src/animation/GroupAnimation.ts +++ b/packages/motion-dom/src/animation/GroupAnimation.ts @@ -86,7 +86,7 @@ export class GroupAnimation implements AnimationPlaybackControls { private runAll( methodName: keyof Omit< AnimationPlaybackControls, - PropNames | "then" | "finished" | "iterationDuration" + PropNames | "then" | "finished" | "iterationDuration" | "setActive" > ) { this.animations.forEach((controls) => controls[methodName]()) diff --git a/packages/motion-dom/src/animation/JSAnimation.ts b/packages/motion-dom/src/animation/JSAnimation.ts index 2f116513e9..45edc110a2 100644 --- a/packages/motion-dom/src/animation/JSAnimation.ts +++ b/packages/motion-dom/src/animation/JSAnimation.ts @@ -6,6 +6,7 @@ import { secondsToMilliseconds, } from "motion-utils" import { time } from "../frameloop/sync-time" +import { camelToDash } from "../render/dom/utils/camel-to-dash" import { mix } from "../utils/mix" import { Mixer } from "../utils/mix/types" import { frameloopDriver } from "./drivers/frame" @@ -547,6 +548,58 @@ export class JSAnimation this.driver?.stop() return timeline.observe(this) } + + private timelineActive = true + + /** + * Activate/deactivate the animation while it's driven by a scroll timeline + * range. Outside the range we remove the rendered style so the CSS cascade + * (e.g. `:hover`) can take over, matching native `animation-range`. The + * value stays bound, so the next in-range scroll update re-applies it. + */ + setActive(isActive: boolean) { + if (isActive === this.timelineActive) return + this.timelineActive = isActive + + const { motionValue, name } = this.options + if (!name) return + + if (isActive) { + /** + * Re-entering the range: re-render the still-bound value's style. We + * can't rely on the next scroll update alone, as it may resolve to + * the same (unchanged) value it deactivated at, which wouldn't + * trigger a render. + */ + ;(this.options.element as { render?: () => void } | undefined)?.render?.() + return + } + + const element = motionValue?.owner?.current as HTMLElement | undefined + if (!element?.style) return + + /** + * For plain styles `removeProperty` clears the inline value. Transform + * values share the combined `transform` property, so target that. + */ + element.style.removeProperty( + isTransform(name) ? "transform" : camelToDash(name) + ) + } +} + +/** + * Lightweight transform-key check that avoids pulling the full transform key + * list (and its bundle cost) into the animation module. Transform values all + * render to the combined `transform` property rather than `name`. + */ +function isTransform(name: string) { + return ( + name === "x" || + name === "y" || + name === "z" || + /^(transform|translate|rotate|scale|skew)/.test(name) + ) } // Legacy function support diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index 93f8201918..4c061c605d 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -250,6 +250,7 @@ export class NativeAnimation timeline, rangeStart, rangeEnd, + fill, observe, }: TimelineWithFallback): VoidFunction { if (this.allowFlatten) { @@ -264,9 +265,30 @@ export class NativeAnimation if (rangeStart) (this.animation as any).rangeStart = rangeStart if (rangeEnd) (this.animation as any).rangeEnd = rangeEnd + /** + * When a user range is set we switch fill to "auto" so the effect is + * removed outside the range, matching native `animation-range`. + */ + if (fill) this.animation.effect?.updateTiming({ fill } as any) + return noop } else { return observe(this) } } + + private timelineActive = true + + setActive(isActive: boolean) { + if (isActive === this.timelineActive) return + this.timelineActive = isActive + + /** + * Outside the active range we cancel the WAAPI animation to remove its + * effect, so the CSS cascade (e.g. `:hover`) can take over. Re-entering + * the range re-applies the effect via the next `time` setter from the + * scroll observer. + */ + if (!isActive) this.cancel() + } } diff --git a/packages/motion-dom/src/animation/types.ts b/packages/motion-dom/src/animation/types.ts index e5b70c9ecd..425d56d8ff 100644 --- a/packages/motion-dom/src/animation/types.ts +++ b/packages/motion-dom/src/animation/types.ts @@ -28,6 +28,7 @@ export interface TimelineWithFallback { timeline?: ProgressTimeline rangeStart?: string rangeEnd?: string + fill?: string observe: (animation: AnimationPlaybackControls) => VoidFunction } @@ -104,6 +105,15 @@ export interface AnimationPlaybackControls { */ attachTimeline: (timeline: TimelineWithFallback) => VoidFunction + /** + * Activates or deactivates the animation while it's driven by a scroll + * timeline range. When deactivated the animation's styles are removed so the + * CSS cascade can take over, mirroring native `animation-range` behaviour. + * + * This is currently for internal use only. + */ + setActive?: (isActive: boolean) => void + finished: Promise }