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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
8 changes: 4 additions & 4 deletions dev/html/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "html-env",
"private": true,
"version": "12.33.1",
"version": "12.34.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions dev/next/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
4 changes: 2 additions & 2 deletions dev/next/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "next-env",
"private": true,
"version": "12.33.1",
"version": "12.34.0",
"type": "module",
"scripts": {
"dev": "next dev",
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions dev/react-19/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-19-env",
"private": true,
"version": "12.33.1",
"version": "12.34.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions dev/react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-env",
"private": true,
"version": "12.33.1",
"version": "12.34.0",
"type": "module",
"scripts": {
"dev": "yarn vite",
Expand All @@ -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"
},
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,
}
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "12.33.1",
"version": "12.34.0",
"packages": [
"packages/*",
"dev/*"
Expand Down
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")
})
})
})
4 changes: 2 additions & 2 deletions packages/framer-motion/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
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
2 changes: 1 addition & 1 deletion packages/motion-dom/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading