diff --git a/CHANGELOG.md b/CHANGELOG.md index 64665f2eab..2ed6ec09cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.34.0] 2026-02-09 + +### Fixed + +- `useScroll`: Hardware accelerated animations. + +## [12.33.2] 2026-02-06 + +### Fixed + +- Improve detection of detached elements with vanilla layout animations. + ## [12.33.1] 2026-02-06 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6eede4bf4c..e8630875d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.tsx` exporting a named `App` component. It's automatically available at `?test=`. +2. **Create a spec** in `packages/framer-motion/cypress/integration/.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: diff --git a/Makefile b/Makefile index 9305f08cc7..dbfe958214 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/dev/html/package.json b/dev/html/package.json index 3d815866f6..071a7a2e6a 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.33.1", + "version": "12.34.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.33.1", - "motion": "^12.33.1", - "motion-dom": "^12.33.1", + "framer-motion": "^12.34.0", + "motion": "^12.34.0", + "motion-dom": "^12.34.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/next-env.d.ts b/dev/next/next-env.d.ts index 1b3be0840f..830fb594ca 100644 --- a/dev/next/next-env.d.ts +++ b/dev/next/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/dev/next/package.json b/dev/next/package.json index f03646c9a0..f5a4153628 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.33.1", + "version": "12.34.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.33.1", + "motion": "^12.34.0", "next": "15.5.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 996031615a..3ee71c3b14 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.33.1", + "version": "12.34.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.33.1", + "motion": "^12.34.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index a8baa0f678..b41af2a113 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.33.1", + "version": "12.34.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.33.1", + "framer-motion": "^12.34.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/react/src/tests/scroll-accelerate.tsx b/dev/react/src/tests/scroll-accelerate.tsx new file mode 100644 index 0000000000..4647f69a30 --- /dev/null +++ b/dev/react/src/tests/scroll-accelerate.tsx @@ -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 ( + <> +
+
+
+
+ + + + {opacity.accelerate ? "true" : "false"} + + + {chainedOpacity.accelerate ? "true" : "false"} + + + {backgroundColor.accelerate ? "true" : "false"} + + + ) +} + +const spacer = { height: "100vh" } +const box: React.CSSProperties = { + position: "fixed", + top: 0, + left: 0, + width: 100, + height: 100, +} diff --git a/lerna.json b/lerna.json index d1f0925b41..9d284f3977 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.33.1", + "version": "12.34.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/cypress/integration/scroll-accelerate.ts b/packages/framer-motion/cypress/integration/scroll-accelerate.ts new file mode 100644 index 0000000000..8027b4922f --- /dev/null +++ b/packages/framer-motion/cypress/integration/scroll-accelerate.ts @@ -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") + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 6da9f86767..a0f9f15e29 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.33.1", + "version": "12.34.0", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.33.1", + "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/framer-motion/src/value/use-scroll.ts b/packages/framer-motion/src/value/use-scroll.ts index ed12c9fe06..0efd38254f 100644 --- a/packages/framer-motion/src/value/use-scroll.ts +++ b/packages/framer-motion/src/value/use-scroll.ts @@ -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" @@ -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(null) const needsStart = useRef(false) diff --git a/packages/framer-motion/src/value/use-transform.ts b/packages/framer-motion/src/value/use-transform.ts index 9c68569e55..999febdf42 100644 --- a/packages/framer-motion/src/value/use-transform.ts +++ b/packages/framer-motion/src/value/use-transform.ts @@ -208,7 +208,7 @@ export function useTransform( ? inputRangeOrTransformer : transform(inputRangeOrTransformer!, outputRange!, options) - return Array.isArray(input) + const result = Array.isArray(input) ? useListTransform( input, transformer as MultiTransformer @@ -216,6 +216,28 @@ export function useTransform( : useListTransform([input], ([latest]) => (transformer as SingleTransformer)(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( diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 0bfb737ecf..42dd9491f9 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.33.1", + "version": "12.34.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index 9d27835627..8a8075891e 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -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", @@ -61,7 +64,9 @@ let featureDefinitions: Partial = {} * 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) { +export function setFeatureDefinitions( + definitions: Partial +) { featureDefinitions = definitions } @@ -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) { @@ -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, () => { @@ -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() } diff --git a/packages/motion-dom/src/value/index.ts b/packages/motion-dom/src/value/index.ts index e591c31290..3a1825123e 100644 --- a/packages/motion-dom/src/value/index.ts +++ b/packages/motion-dom/src/value/index.ts @@ -1,4 +1,9 @@ -import { SubscriptionManager, velocityPerSecond, warnOnce } from "motion-utils" +import { + EasingFunction, + SubscriptionManager, + velocityPerSecond, + warnOnce, +} from "motion-utils" import { AnimationPlaybackControlsWithThen, AnyResolvedKeyframe, @@ -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 } @@ -141,6 +155,13 @@ export class MotionValue { */ 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 diff --git a/packages/motion/package.json b/packages/motion/package.json index 2519b7dd05..e418097564 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.33.1", + "version": "12.34.0", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.33.1", + "framer-motion": "^12.34.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index e8bc0312c2..e9dd6863e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.33.1, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.34.0, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.33.1 + motion-dom: ^12.34.0 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8209,9 +8209,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.33.1 - motion: ^12.33.1 - motion-dom: ^12.33.1 + framer-motion: ^12.34.0 + motion: ^12.34.0 + motion-dom: ^12.34.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10953,7 +10953,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.33.1, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.34.0, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11032,11 +11032,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.33.1, motion@workspace:packages/motion": +"motion@^12.34.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.33.1 + framer-motion: ^12.34.0 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11153,7 +11153,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.33.1 + motion: ^12.34.0 next: 15.5.10 react: 19.0.0 react-dom: 19.0.0 @@ -12625,7 +12625,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.33.1 + motion: ^12.34.0 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12709,7 +12709,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.33.1 + framer-motion: ^12.34.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0