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
}