-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add rangeStart/rangeEnd to scroll() that deactivate JS and native animations #3758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mattgperry
wants to merge
1
commit into
main
Choose a base branch
from
fix-3001-scroll-range-deactivation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
42
packages/framer-motion/cypress/integration/scroll-range.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
136
packages/framer-motion/src/render/dom/scroll/__tests__/range.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
packages/framer-motion/src/render/dom/scroll/utils/range.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveRangeFraction("cover 50%", fallback)returns the fallback (0 or 1) becauseparseFloat("cover 50%")is NaN. When a user on Safari or Firefox passesrangeStart: "cover 50%"— a valid WAAPI string that the API'sstring | numbertype explicitly accepts — the JS path silently treats it as0instead 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.