Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions dev/react/src/tests/scroll-range.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<style>{`#box { opacity: 0.1; }`}</style>
<div id="native-timeline" style={{ position: "fixed", bottom: 0 }}>
{nativeTimeline ? "native" : "fallback"}
</div>
<div style={spacer} />
<div style={spacer} />
<div style={spacer} />
<div style={spacer} />
<div style={spacer} />
<div id="box" style={box} />
</>
)
}

const spacer: React.CSSProperties = { height: "100vh" }

const box: React.CSSProperties = {
position: "fixed",
top: 0,
left: 0,
width: 100,
height: 100,
backgroundColor: "red",
}
42 changes: 42 additions & 0 deletions packages/framer-motion/cypress/integration/scroll-range.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
136 changes: 136 additions & 0 deletions packages/framer-motion/src/render/dom/scroll/__tests__/range.test.ts
Original file line number Diff line number Diff line change
@@ -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<Element, Record<string, number>>()

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<void>((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()
})
})
70 changes: 63 additions & 7 deletions packages/framer-motion/src/render/dom/scroll/attach-animation.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
Comment on lines +69 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 WAAPI named range strings silently ignored on JS-observe path

resolveRangeFraction("cover 50%", fallback) returns the fallback (0 or 1) because parseFloat("cover 50%") is NaN. When a user on Safari or Firefox passes rangeStart: "cover 50%" — a valid WAAPI string that the API's string | number type explicitly accepts — the JS path silently treats it as 0 instead of raising any warning. The native path (Chrome) forwards the string unchanged to the browser, so the two code paths diverge invisibly. A console warning when a WAAPI-keyword string is detected on the JS path would prevent silent misbehaviour.

}

return observeTimeline((progress) => {
valueAnimation.time =
valueAnimation.iterationDuration * progress
}, timeline)
}, timeline!)
},
})
}
12 changes: 12 additions & 0 deletions packages/framer-motion/src/render/dom/scroll/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions packages/framer-motion/src/render/dom/scroll/utils/range.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion packages/motion-dom/src/animation/GroupAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]())
Expand Down
Loading