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
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,21 @@ motion (public API)

## Writing Tests

**IMPORTANT: Always write a failing test FIRST before implementing any bug fix or feature.** This ensures the issue is reproducible and the fix is verified. For UI interaction bugs (like gesture handling), prefer E2E tests using Playwright or Cypress.
**IMPORTANT: Always write tests for every bug fix AND every new feature.** Write a failing test FIRST before implementing, to ensure the issue is reproducible and the fix is verified.

### Test types by feature

- **Unit tests (Jest)**: For pure logic, value transformations, utilities. Located in `__tests__/` directories alongside source.
- **E2E tests (Cypress)**: For UI behavior that involves DOM rendering, scroll interactions, gesture handling, or WAAPI animations. Test specs in `packages/framer-motion/cypress/integration/`, test pages in `dev/react/src/tests/`.
- **E2E tests (Playwright)**: For cross-browser testing and HTML/vanilla JS tests. Specs in `tests/`, test pages in `dev/html/public/playwright/`.

### Creating Cypress E2E tests

1. **Create a test page** in `dev/react/src/tests/<test-name>.tsx` exporting a named `App` component. It's automatically available at `?test=<test-name>`.
2. **Create a spec** in `packages/framer-motion/cypress/integration/<test-name>.ts`.
3. **Verify WAAPI acceleration** using `element.getAnimations()` in Cypress `should` callbacks to check that native animations are (or aren't) created.

### Async test helpers

When waiting for the next frame in async tests:

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ test-e2e: test-nextjs test-html test-react test-react-19
yarn test-playwright

test-single: build test-mkdir
yarn start-server-and-test "yarn dev-server" http://localhost:9990 "cd packages/framer-motion && cypress run --config-file=cypress.json --headed --spec cypress/integration/drag-momentum.ts"
yarn start-server-and-test "yarn dev-server" http://localhost:9990 "cd packages/framer-motion && cypress run --config-file=cypress.json --headed --spec cypress/integration/scroll-accelerate.ts"

lint: bootstrap
yarn lint
Expand Down
50 changes: 50 additions & 0 deletions dev/react/src/tests/scroll-accelerate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { motion, useScroll, useTransform } from "framer-motion"
import * as React from "react"

export const App = () => {
const { scrollYProgress } = useScroll()
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [1, 0.5, 0])
const backgroundColor = useTransform(
scrollYProgress,
[0, 1],
["#ff0000", "#0000ff"]
)

const intermediate = useTransform(scrollYProgress, [0, 1], [1, 0.5])
const chainedOpacity = useTransform(intermediate, [1, 0.75], [0, 1])

return (
<>
<div style={spacer} />
<div style={spacer} />
<div style={spacer} />
<div style={spacer} />
<motion.div
id="direct"
style={{ ...box, opacity, backgroundColor }}
/>
<motion.div
id="chained"
style={{ ...box, opacity: chainedOpacity, top: 110 }}
/>
<span id="direct-accelerated">
{opacity.accelerate ? "true" : "false"}
</span>
<span id="chained-accelerated">
{chainedOpacity.accelerate ? "true" : "false"}
</span>
<span id="bg-accelerated">
{backgroundColor.accelerate ? "true" : "false"}
</span>
</>
)
}

const spacer = { height: "100vh" }
const box: React.CSSProperties = {
position: "fixed",
top: 0,
left: 0,
width: 100,
height: 100,
}
31 changes: 31 additions & 0 deletions packages/framer-motion/cypress/integration/scroll-accelerate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
describe("scroll timeline WAAPI acceleration", () => {
it("Propagates acceleration for direct useTransform from scroll", () => {
cy.visit("?test=scroll-accelerate")
.wait(200)
.get("#direct-accelerated")
.should(([$el]: any) => {
expect($el.innerText).to.equal("true")
})
})

it("Propagates acceleration for non-acceleratable properties too", () => {
cy.visit("?test=scroll-accelerate")
.wait(200)
.get("#bg-accelerated")
.should(([$el]: any) => {
// backgroundColor gets accelerate config propagated,
// but VisualElement skips WAAPI creation since it's
// not in the acceleratedValues set
expect($el.innerText).to.equal("true")
})
})

it("Does not propagate acceleration for chained useTransform", () => {
cy.visit("?test=scroll-accelerate")
.wait(200)
.get("#chained-accelerated")
.should(([$el]: any) => {
expect($el.innerText).to.equal("false")
})
})
})
30 changes: 29 additions & 1 deletion packages/framer-motion/src/value/use-scroll.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { motionValue } from "motion-dom"
import { AnimationPlaybackControls, motionValue } from "motion-dom"
import { invariant } from "motion-utils"
import { RefObject, useCallback, useEffect, useRef } from "react"
import { scroll } from "../render/dom/scroll"
Expand Down Expand Up @@ -32,6 +32,34 @@ export function useScroll({
...options
}: UseScrollOptions = {}) {
const values = useConstant(createScrollMotionValues)

values.scrollXProgress.accelerate = {
factory: (animation: AnimationPlaybackControls) =>
scroll(animation, {
...options,
axis: "x",
container: container?.current || undefined,
target: target?.current || undefined,
}),
times: [0, 1],
keyframes: [0, 1],
ease: (v: number) => v,
duration: 1,
}
values.scrollYProgress.accelerate = {
factory: (animation: AnimationPlaybackControls) =>
scroll(animation, {
...options,
axis: "y",
container: container?.current || undefined,
target: target?.current || undefined,
}),
times: [0, 1],
keyframes: [0, 1],
ease: (v: number) => v,
duration: 1,
}

const scrollAnimation = useRef<VoidFunction | null>(null)
const needsStart = useRef(false)

Expand Down
24 changes: 23 additions & 1 deletion packages/framer-motion/src/value/use-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,36 @@ export function useTransform<I, O, K extends string>(
? inputRangeOrTransformer
: transform(inputRangeOrTransformer!, outputRange!, options)

return Array.isArray(input)
const result = Array.isArray(input)
? useListTransform(
input,
transformer as MultiTransformer<AnyResolvedKeyframe, O>
)
: useListTransform([input], ([latest]) =>
(transformer as SingleTransformer<I, O>)(latest)
)

const inputAccelerate = !Array.isArray(input)
? (input as MotionValue).accelerate
: undefined

if (
inputAccelerate &&
!inputAccelerate.isTransformed &&
typeof inputRangeOrTransformer !== "function" &&
Array.isArray(outputRangeOrMap) &&
options?.clamp !== false
) {
result.accelerate = {
...inputAccelerate,
times: inputRangeOrTransformer as number[],
keyframes: outputRangeOrMap,
isTransformed: true,
...(options?.ease ? { ease: options.ease } : {}),
}
}

return result
}

function useListTransform<I, O>(
Expand Down
85 changes: 63 additions & 22 deletions packages/motion-dom/src/render/VisualElement.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,49 @@
import { Box } from "motion-utils"
import {
Box,
isNumericalString,
isZeroValueString,
secondsToMilliseconds,
SubscriptionManager,
warnOnce,
} from "motion-utils"
import { KeyframeResolver } from "../animation/keyframes/KeyframesResolver"
import { NativeAnimation } from "../animation/NativeAnimation"
import type { AnyResolvedKeyframe } from "../animation/types"
import { acceleratedValues } from "../animation/waapi/utils/accelerated-values"
import { cancelFrame, frame } from "../frameloop"
import { microtask } from "../frameloop/microtask"
import { time } from "../frameloop/sync-time"
import type { MotionNodeOptions } from "../node/types"
import { createBox } from "../projection/geometry/models"
import { motionValue, MotionValue } from "../value"
import { isMotionValue } from "../value/utils/is-motion-value"
import { KeyframeResolver } from "../animation/keyframes/KeyframesResolver"
import type { AnyResolvedKeyframe } from "../animation/types"
import { transformProps } from "./utils/keys-transform"
import { complex } from "../value/types/complex"
import { findValueType } from "../value/types/utils/find"
import { getAnimatableNone } from "../value/types/utils/animatable-none"
import type { MotionNodeOptions } from "../node/types"
import { createBox } from "../projection/geometry/models"
import {
initPrefersReducedMotion,
hasReducedMotionListener,
prefersReducedMotion,
} from "./utils/reduced-motion"
import { findValueType } from "../value/types/utils/find"
import { isMotionValue } from "../value/utils/is-motion-value"
import { Feature } from "./Feature"
import { visualElementStore } from "./store"
import {
FeatureDefinitions,
MotionConfigContextProps,
PresenceContextProps,
ReducedMotionConfig,
ResolvedValues,
VisualElementEventCallbacks,
VisualElementOptions,
PresenceContextProps,
ReducedMotionConfig,
FeatureDefinitions,
MotionConfigContextProps,
} from "./types"
import { AnimationState } from "./utils/animation-state"
import {
isControllingVariants as checkIsControllingVariants,
isVariantNode as checkIsVariantNode,
} from "./utils/is-controlling-variants"
import { transformProps } from "./utils/keys-transform"
import { updateMotionValuesFromProps } from "./utils/motion-values"
import {
hasReducedMotionListener,
initPrefersReducedMotion,
prefersReducedMotion,
} from "./utils/reduced-motion"
import { resolveVariantFromProps } from "./utils/resolve-variants"
import { Feature } from "./Feature"

const propEventHandlers = [
"AnimationStart",
Expand All @@ -61,7 +64,9 @@ let featureDefinitions: Partial<FeatureDefinitions> = {}
* Set feature definitions for all VisualElements.
* This should be called by the framework layer (e.g., framer-motion) during initialization.
*/
export function setFeatureDefinitions(definitions: Partial<FeatureDefinitions>) {
export function setFeatureDefinitions(
definitions: Partial<FeatureDefinitions>
) {
featureDefinitions = definitions
}

Expand Down Expand Up @@ -535,6 +540,32 @@ export abstract class VisualElement<
this.valueSubscriptions.get(key)!()
}

if (
value.accelerate &&
acceleratedValues.has(key) &&
this.current instanceof HTMLElement
) {
const { factory, keyframes, times, ease, duration } =
value.accelerate

const animation = new NativeAnimation({
element: this.current,
name: key,
keyframes,
times,
ease,
duration: secondsToMilliseconds(duration),
})

const cleanup = factory(animation)

this.valueSubscriptions.set(key, () => {
cleanup()
animation.cancel()
})
return
}

const valueIsTransform = transformProps.has(key)

if (valueIsTransform && this.onBindTransform) {
Expand All @@ -557,8 +588,15 @@ export abstract class VisualElement<
)

let removeSyncCheck: VoidFunction | void
if (typeof window !== "undefined" && (window as any).MotionCheckAppearSync) {
removeSyncCheck = (window as any).MotionCheckAppearSync(this, key, value)
if (
typeof window !== "undefined" &&
(window as any).MotionCheckAppearSync
) {
removeSyncCheck = (window as any).MotionCheckAppearSync(
this,
key,
value
)
}

this.valueSubscriptions.set(key, () => {
Expand Down Expand Up @@ -671,7 +709,10 @@ export abstract class VisualElement<
* Update the provided props. Ensure any newly-added motion values are
* added to our map, old ones removed, and listeners updated.
*/
update(props: MotionNodeOptions, presenceContext: PresenceContextProps | null) {
update(
props: MotionNodeOptions,
presenceContext: PresenceContextProps | null
) {
if (props.transformTemplate || this.props.transformTemplate) {
this.scheduleRender()
}
Expand Down
23 changes: 22 additions & 1 deletion packages/motion-dom/src/value/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { SubscriptionManager, velocityPerSecond, warnOnce } from "motion-utils"
import {
EasingFunction,
SubscriptionManager,
velocityPerSecond,
warnOnce,
} from "motion-utils"
import {
AnimationPlaybackControlsWithThen,
AnyResolvedKeyframe,
Expand Down Expand Up @@ -54,6 +59,15 @@ export interface Owner {
}
}

export interface AccelerateConfig {
factory: (animation: AnimationPlaybackControlsWithThen) => VoidFunction
times: number[]
keyframes: any[]
ease?: EasingFunction | EasingFunction[]
duration: number
isTransformed?: boolean
}

export interface MotionValueOptions {
owner?: Owner
}
Expand Down Expand Up @@ -141,6 +155,13 @@ export class MotionValue<V = any> {
*/
liveStyle?: boolean

/**
* Scroll timeline acceleration metadata. When set, VisualElement
* can create a native WAAPI animation attached to a scroll timeline
* instead of driving updates through JS.
*/
accelerate?: AccelerateConfig

/**
* @param init - The initiating value
* @param config - Optional configuration options
Expand Down