Skip to content
Merged
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
75 changes: 75 additions & 0 deletions dev/html/public/playwright/scroll/scroll-range.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<html>
<head>
<style>
body {
margin: 0;
}

.spacer {
height: 100vh;
}

#target {
height: 500px;
}

.box {
position: fixed;
left: 0;
width: 100px;
height: 100px;
background-color: #0077ff;
}
</style>
</head>
<body>
<div class="spacer"></div>
<div class="spacer"></div>
<div style="height: 50vh"></div>
<div id="target"></div>
<div class="spacer"></div>
<div class="spacer"></div>
<div class="box" id="box" style="top: 0"></div>
<div class="box" id="js-box" style="top: 100px"></div>
<div class="box" id="target-box" style="top: 200px"></div>
<div class="box" id="target-js-box" style="top: 300px"></div>
<script type="module" src="/src/inc.js"></script>
<script type="module">
const { animate, scroll } = window.Motion

/**
* scroll() with rangeStart/rangeEnd maps the range onto the
* animation and holds the first keyframe before it and the last
* keyframe after it.
*
* opacity runs on WAAPI, so it uses a native ScrollTimeline or
* ViewTimeline where supported. x never does, so it always uses
* the JS observe path.
*/
const target = document.getElementById("target")
const transition = { ease: "linear" }
const targetRange = { target, rangeStart: 0, rangeEnd: 0.5 }

const stops = [
scroll(animate("#box", { opacity: [0, 1] }, transition), {
rangeStart: 0.1,
rangeEnd: 0.3,
}),
scroll(animate("#js-box", { x: [0, 100] }, transition), {
rangeStart: 0.1,
rangeEnd: 0.3,
}),
scroll(
animate("#target-box", { opacity: [0, 1] }, transition),
targetRange
),
scroll(
animate("#target-js-box", { x: [0, 100] }, transition),
targetRange
),
]

window.stopScroll = () => stops.forEach((stop) => stop())
</script>
</body>
</html>
72 changes: 72 additions & 0 deletions dev/react/src/tests/scroll-range.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { animate, scroll } from "framer-motion"
import * as React from "react"
import { useEffect } from "react"

/**
* scroll() with rangeStart/rangeEnd maps the range onto the animation and
* holds the first keyframe before it and the last keyframe after it.
*
* opacity runs on WAAPI, so it uses a native ScrollTimeline/ViewTimeline where
* supported. x never does, so it always uses the JS observe path.
*/
export const App = () => {
useEffect(() => {
const target = document.getElementById("target")!
const transition = { ease: "linear" } as const
const targetRange = {
target,
rangeStart: 0,
rangeEnd: 0.5,
} as const

const stops = [
scroll(animate("#box", { opacity: [0, 1] }, transition), {
rangeStart: 0.1,
rangeEnd: 0.3,
}),
scroll(animate("#js-box", { x: [0, 100] }, transition), {
rangeStart: 0.1,
rangeEnd: 0.3,
}),
scroll(
animate("#target-box", { opacity: [0, 1] }, transition),
targetRange
),
scroll(
animate("#target-js-box", { x: [0, 100] }, transition),
targetRange
),
]

const stopAll = () => stops.forEach((stop) => stop())
Object.assign(window, { stopScroll: stopAll })

return stopAll
}, [])

return (
<>
<style>{`body { margin: 0; }`}</style>
<div style={spacer} />
<div style={spacer} />
<div style={{ height: "50vh" }} />
<div id="target" style={{ height: 500 }} />
<div style={spacer} />
<div style={spacer} />
<div id="box" style={{ ...box, top: 0 }} />
<div id="js-box" style={{ ...box, top: 100 }} />
<div id="target-box" style={{ ...box, top: 200 }} />
<div id="target-js-box" style={{ ...box, top: 300 }} />
</>
)
}

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

const box: React.CSSProperties = {
position: "fixed",
left: 0,
width: 100,
height: 100,
backgroundColor: "red",
}
99 changes: 99 additions & 0 deletions packages/framer-motion/cypress/integration/scroll-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* scroll() rangeStart/rangeEnd map the range onto the animation and hold the
* first keyframe before it and the last keyframe after it.
*
* Page height = 5 * 100vh, so with a 1000px viewport scrollLength = 4000px and
* the page range 0.1–0.3 is 400–1200px.
*
* #target is 500px tall at top 2500px, so its cover range runs from scroll
* 1500px (top meets viewport bottom) to 3000px (bottom meets viewport top),
* and the target range 0–0.5 is 1500–2250px.
*
* In browsers with ScrollTimeline, the opacity boxes run natively and the x
* boxes via the JS observe path, so the two paths are checked against each
* other.
*/
const getX = (transform: string) =>
transform === "none" ? 0 : parseFloat(transform.split(",")[4])

const expectOpacity = (id: string, progress: number) =>
cy.get(id).should(([$el]: any) => {
const opacity = parseFloat(getComputedStyle($el).opacity)
expect(opacity).to.be.closeTo(progress, 0.05)
})

const expectX = (id: string, progress: number) =>
cy.get(id).should(([$el]: any) => {
const x = getX(getComputedStyle($el).transform)
expect(x / 100).to.be.closeTo(progress, 0.05)
})

const scrollThrough = (
[opacityId, xId]: string[],
positions: Array<[number, number]>
) => {
for (const [y, progress] of positions) {
cy.scrollTo(0, y).wait(200)
expectOpacity(opacityId, progress)
expectX(xId, progress)
}
}

describe("scroll() rangeStart/rangeEnd", () => {
beforeEach(() => {
cy.viewport(1000, 1000)
cy.visit("?test=scroll-range").wait(200)
})

it("Runs WAAPI values on native timelines where supported", () => {
cy.window().then((win: any) => {
if (!win.ScrollTimeline) return

const timeline = (id: string) =>
win.document.querySelector(id).getAnimations()[0].timeline

expect(timeline("#box")).to.be.instanceOf(win.ScrollTimeline)
expect(timeline("#target-box")).to.be.instanceOf(win.ViewTimeline)
})
})

it("Holds either side of the page range", () => {
scrollThrough(
["#box", "#js-box"],
[
[0, 0],
[800, 0.5],
[2000, 1],
[800, 0.5],
[0, 0],
[2000, 1],
]
)
})

it("Holds either side of the target's cover range", () => {
scrollThrough(
["#target-box", "#target-js-box"],
[
[1000, 0],
[1875, 0.5],
[2700, 1],
[1875, 0.5],
[1000, 0],
]
)
})

/**
* The WAAPI boxes aren't checked: NativeAnimation.stop() doesn't stop
* scroll-driven animations at their scroll position, with or without a
* range.
*/
it("Stopping keeps JS values where they are", () => {
cy.scrollTo(0, 800).wait(200)
cy.window().then((win: any) => win.stopScroll())
cy.scrollTo(0, 2000).wait(200)
expectX("#js-box", 0.5)
expectX("#target-js-box", 0)
})
})
Loading