From 3b6dcbd77113dae9edeaef4062b5a12d52f2585c Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 06:35:57 +0100 Subject: [PATCH 01/12] Add scroll timeline acceleration for useScroll + useTransform Thread scroll timeline metadata through MotionValue so that when a scroll-derived value is bound to an acceleratable CSS property (opacity, clipPath, filter), a native WAAPI animation is created and attached to the scroll timeline directly, bypassing JS per-frame updates. Co-Authored-By: Claude Opus 4.6 --- .../framer-motion/src/value/use-scroll.ts | 30 ++++++++++++++++++- .../framer-motion/src/value/use-transform.ts | 19 +++++++++++- .../motion-dom/src/render/VisualElement.ts | 30 +++++++++++++++++++ packages/motion-dom/src/value/index.ts | 22 +++++++++++++- 4 files changed, 98 insertions(+), 3 deletions(-) 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..05191cfbf0 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,23 @@ export function useTransform( : useListTransform([input], ([latest]) => (transformer as SingleTransformer)(latest) ) + + if ( + !Array.isArray(input) && + typeof inputRangeOrTransformer !== "function" && + Array.isArray(outputRangeOrMap) && + (input as MotionValue).accelerate && + options?.clamp !== false + ) { + result.accelerate = { + ...(input as MotionValue).accelerate!, + times: inputRangeOrTransformer as number[], + keyframes: outputRangeOrMap, + ...(options?.ease ? { ease: options.ease } : {}), + } + } + + return result } function useListTransform( diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index 9d27835627..d00c631830 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -2,9 +2,12 @@ import { Box } from "motion-utils" import { isNumericalString, isZeroValueString, + secondsToMilliseconds, SubscriptionManager, warnOnce, } from "motion-utils" +import { NativeAnimation } from "../animation/NativeAnimation" +import { acceleratedValues } from "../animation/waapi/utils/accelerated-values" import { cancelFrame, frame } from "../frameloop" import { microtask } from "../frameloop/microtask" import { time } from "../frameloop/sync-time" @@ -535,6 +538,33 @@ 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), + autoplay: false, + }) + + const cleanup = factory(animation) + + this.valueSubscriptions.set(key, () => { + cleanup() + animation.cancel() + }) + return + } + const valueIsTransform = transformProps.has(key) if (valueIsTransform && this.onBindTransform) { diff --git a/packages/motion-dom/src/value/index.ts b/packages/motion-dom/src/value/index.ts index e591c31290..819e565f96 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,14 @@ export interface Owner { } } +export interface AccelerateConfig { + factory: (animation: AnimationPlaybackControlsWithThen) => VoidFunction + times: number[] + keyframes: any[] + ease?: EasingFunction | EasingFunction[] + duration: number +} + export interface MotionValueOptions { owner?: Owner } @@ -141,6 +154,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 From 7742912b25d59e6c1cb09df1870993fa3bf700f1 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 08:41:27 +0100 Subject: [PATCH 02/12] Update CLAUDE.md with testing guidance and fix chained useTransform acceleration - Expand CLAUDE.md testing section: document test types, Cypress E2E patterns, and scroll timeline acceleration test requirements - Fix chained useTransform bug: only propagate accelerate when input is a direct scroll progress value (times [0,1], keyframes [0,1]). Prevents incorrect WAAPI animations when transforms are chained. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 30 ++++++++++++++++++- .../framer-motion/src/value/use-transform.ts | 17 ++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6eede4bf4c..890618671a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,35 @@ 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. + +### What to test for scroll timeline acceleration + +When adding scroll-driven WAAPI acceleration features, always write Cypress tests that: + +- **Verify acceleration fires**: Use `element.getAnimations().length` to confirm WAAPI animations exist for acceleratable properties (opacity, clipPath, filter). +- **Verify non-accelerated properties fall back to JS**: Properties like backgroundColor run on the main thread — check they do NOT produce WAAPI animations. +- **Verify chained useTransform does NOT accelerate**: If a useTransform output is fed into a second useTransform, the scroll-to-value mapping is lost. The second transform's input range is in the output space of the first, not the original scroll space. Acceleration must be disabled. Example: + ```jsx + const { scrollYProgress } = useScroll() + const a = useTransform(scrollYProgress, [0, 1], [0, 0.5]) + const b = useTransform(a, [0, 0.5], [0, 0.25]) + // b must NOT have a WAAPI animation — the times/keyframes don't map to scroll progress + ``` + +### Async test helpers When waiting for the next frame in async tests: diff --git a/packages/framer-motion/src/value/use-transform.ts b/packages/framer-motion/src/value/use-transform.ts index 05191cfbf0..932e2c6d44 100644 --- a/packages/framer-motion/src/value/use-transform.ts +++ b/packages/framer-motion/src/value/use-transform.ts @@ -217,15 +217,24 @@ export function useTransform( (transformer as SingleTransformer)(latest) ) + const inputAccelerate = !Array.isArray(input) + ? (input as MotionValue).accelerate + : undefined + if ( - !Array.isArray(input) && + inputAccelerate && typeof inputRangeOrTransformer !== "function" && Array.isArray(outputRangeOrMap) && - (input as MotionValue).accelerate && - options?.clamp !== false + options?.clamp !== false && + inputAccelerate.times.length === 2 && + inputAccelerate.times[0] === 0 && + inputAccelerate.times[1] === 1 && + inputAccelerate.keyframes.length === 2 && + inputAccelerate.keyframes[0] === 0 && + inputAccelerate.keyframes[1] === 1 ) { result.accelerate = { - ...(input as MotionValue).accelerate!, + ...inputAccelerate, times: inputRangeOrTransformer as number[], keyframes: outputRangeOrMap, ...(options?.ease ? { ease: options.ease } : {}), From 6ec6b387f035315aef4628d41b4b077360b2f76d Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 09:50:00 +0100 Subject: [PATCH 03/12] Add scroll acceleration E2E tests and simplify chained useTransform guard Replace verbose identity check (times/keyframes === [0,1]) with an isTransformed flag on AccelerateConfig. The first useTransform from scroll progress propagates acceleration with isTransformed: true; subsequent chained transforms see the flag and skip acceleration, falling back to the JS onChange path. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 14 ---- dev/react/src/tests/scroll-accelerate.tsx | 64 +++++++++++++++++++ .../cypress/integration/scroll-accelerate.ts | 21 ++++++ .../framer-motion/src/value/use-transform.ts | 10 +-- packages/motion-dom/src/value/index.ts | 1 + 5 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 dev/react/src/tests/scroll-accelerate.tsx create mode 100644 packages/framer-motion/cypress/integration/scroll-accelerate.ts diff --git a/CLAUDE.md b/CLAUDE.md index 890618671a..e8630875d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,20 +87,6 @@ motion (public API) 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. -### What to test for scroll timeline acceleration - -When adding scroll-driven WAAPI acceleration features, always write Cypress tests that: - -- **Verify acceleration fires**: Use `element.getAnimations().length` to confirm WAAPI animations exist for acceleratable properties (opacity, clipPath, filter). -- **Verify non-accelerated properties fall back to JS**: Properties like backgroundColor run on the main thread — check they do NOT produce WAAPI animations. -- **Verify chained useTransform does NOT accelerate**: If a useTransform output is fed into a second useTransform, the scroll-to-value mapping is lost. The second transform's input range is in the output space of the first, not the original scroll space. Acceleration must be disabled. Example: - ```jsx - const { scrollYProgress } = useScroll() - const a = useTransform(scrollYProgress, [0, 1], [0, 0.5]) - const b = useTransform(a, [0, 0.5], [0, 0.25]) - // b must NOT have a WAAPI animation — the times/keyframes don't map to scroll progress - ``` - ### Async test helpers When waiting for the next frame in async tests: diff --git a/dev/react/src/tests/scroll-accelerate.tsx b/dev/react/src/tests/scroll-accelerate.tsx new file mode 100644 index 0000000000..123acae8ea --- /dev/null +++ b/dev/react/src/tests/scroll-accelerate.tsx @@ -0,0 +1,64 @@ +import { motion, useScroll, useTransform } from "framer-motion" +import * as React from "react" +import { useEffect, useRef } 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]) + + const directRef = useRef(null) + const chainedRef = useRef(null) + + useEffect(() => { + requestAnimationFrame(() => { + if (directRef.current) { + document.getElementById("direct-count")!.innerText = + String(directRef.current.getAnimations().length) + } + if (chainedRef.current) { + document.getElementById("chained-count")!.innerText = + String(chainedRef.current.getAnimations().length) + } + }) + }, []) + + return ( + <> +
+
+
+
+ + -1 + + + -1 + + + ) +} + +const spacer = { height: "100vh" } +const box: React.CSSProperties = { + position: "fixed", + top: 0, + left: 0, + width: 100, + height: 100, +} 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..c78ba76573 --- /dev/null +++ b/packages/framer-motion/cypress/integration/scroll-accelerate.ts @@ -0,0 +1,21 @@ +describe("scroll timeline WAAPI acceleration", () => { + it("Creates WAAPI animation for accelerated property, not for non-accelerated", () => { + cy.visit("?test=scroll-accelerate") + .wait(200) + .get("#direct-count") + .should(([$el]: any) => { + // opacity is acceleratable, backgroundColor is not → 1 animation + expect($el.innerText).to.equal("1") + }) + }) + + it("Does not create WAAPI animation for chained useTransform", () => { + cy.visit("?test=scroll-accelerate") + .wait(200) + .get("#chained-count") + .should(([$el]: any) => { + // Chained useTransform should NOT accelerate + expect($el.innerText).to.equal("0") + }) + }) +}) diff --git a/packages/framer-motion/src/value/use-transform.ts b/packages/framer-motion/src/value/use-transform.ts index 932e2c6d44..999febdf42 100644 --- a/packages/framer-motion/src/value/use-transform.ts +++ b/packages/framer-motion/src/value/use-transform.ts @@ -223,20 +223,16 @@ export function useTransform( if ( inputAccelerate && + !inputAccelerate.isTransformed && typeof inputRangeOrTransformer !== "function" && Array.isArray(outputRangeOrMap) && - options?.clamp !== false && - inputAccelerate.times.length === 2 && - inputAccelerate.times[0] === 0 && - inputAccelerate.times[1] === 1 && - inputAccelerate.keyframes.length === 2 && - inputAccelerate.keyframes[0] === 0 && - inputAccelerate.keyframes[1] === 1 + options?.clamp !== false ) { result.accelerate = { ...inputAccelerate, times: inputRangeOrTransformer as number[], keyframes: outputRangeOrMap, + isTransformed: true, ...(options?.ease ? { ease: options.ease } : {}), } } diff --git a/packages/motion-dom/src/value/index.ts b/packages/motion-dom/src/value/index.ts index 819e565f96..3a1825123e 100644 --- a/packages/motion-dom/src/value/index.ts +++ b/packages/motion-dom/src/value/index.ts @@ -65,6 +65,7 @@ export interface AccelerateConfig { keyframes: any[] ease?: EasingFunction | EasingFunction[] duration: number + isTransformed?: boolean } export interface MotionValueOptions { From 6e4c2248f2ff30bc6ad66b8a5f946a8b9a0241ae Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 11:29:57 +0100 Subject: [PATCH 04/12] Fix scroll-accelerate Cypress tests to check getAnimations() directly The previous approach used a single requestAnimationFrame inside useEffect to report animation counts as text content. This was unreliable in CI because the RAF could fire before the VisualElement had created the WAAPI animation (e.g. due to StrictMode double-mount timing). Now Cypress checks element.getAnimations() directly in .should() callbacks, which retry automatically until the assertion passes. Co-Authored-By: Claude Opus 4.6 --- dev/react/src/tests/scroll-accelerate.tsx | 29 ++----------------- .../cypress/integration/scroll-accelerate.ts | 8 ++--- 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/dev/react/src/tests/scroll-accelerate.tsx b/dev/react/src/tests/scroll-accelerate.tsx index 123acae8ea..16fcf9cba4 100644 --- a/dev/react/src/tests/scroll-accelerate.tsx +++ b/dev/react/src/tests/scroll-accelerate.tsx @@ -1,6 +1,5 @@ import { motion, useScroll, useTransform } from "framer-motion" import * as React from "react" -import { useEffect, useRef } from "react" export const App = () => { const { scrollYProgress } = useScroll() @@ -14,22 +13,6 @@ export const App = () => { const intermediate = useTransform(scrollYProgress, [0, 1], [1, 0.5]) const chainedOpacity = useTransform(intermediate, [1, 0.75], [0, 1]) - const directRef = useRef(null) - const chainedRef = useRef(null) - - useEffect(() => { - requestAnimationFrame(() => { - if (directRef.current) { - document.getElementById("direct-count")!.innerText = - String(directRef.current.getAnimations().length) - } - if (chainedRef.current) { - document.getElementById("chained-count")!.innerText = - String(chainedRef.current.getAnimations().length) - } - }) - }, []) - return ( <>
@@ -37,19 +20,13 @@ export const App = () => {
- -1 - + /> - -1 - + style={{ ...box, opacity: chainedOpacity, top: 110 }} + /> ) } diff --git a/packages/framer-motion/cypress/integration/scroll-accelerate.ts b/packages/framer-motion/cypress/integration/scroll-accelerate.ts index c78ba76573..115e25dc67 100644 --- a/packages/framer-motion/cypress/integration/scroll-accelerate.ts +++ b/packages/framer-motion/cypress/integration/scroll-accelerate.ts @@ -2,20 +2,20 @@ describe("scroll timeline WAAPI acceleration", () => { it("Creates WAAPI animation for accelerated property, not for non-accelerated", () => { cy.visit("?test=scroll-accelerate") .wait(200) - .get("#direct-count") + .get("#direct") .should(([$el]: any) => { // opacity is acceleratable, backgroundColor is not → 1 animation - expect($el.innerText).to.equal("1") + expect($el.getAnimations().length).to.equal(1) }) }) it("Does not create WAAPI animation for chained useTransform", () => { cy.visit("?test=scroll-accelerate") .wait(200) - .get("#chained-count") + .get("#chained") .should(([$el]: any) => { // Chained useTransform should NOT accelerate - expect($el.innerText).to.equal("0") + expect($el.getAnimations().length).to.equal(0) }) }) }) From 76d07750e6cc2e4aeea684dc34facd437a1925fa Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:05:04 +0100 Subject: [PATCH 05/12] Fixing --- .../motion-dom/src/render/VisualElement.ts | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index d00c631830..793729eaf3 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -1,49 +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", @@ -64,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 } @@ -545,7 +547,7 @@ export abstract class VisualElement< ) { const { factory, keyframes, times, ease, duration } = value.accelerate - + console.log(name, keyframes, times, ease) const animation = new NativeAnimation({ element: this.current, name: key, @@ -553,7 +555,6 @@ export abstract class VisualElement< times, ease, duration: secondsToMilliseconds(duration), - autoplay: false, }) const cleanup = factory(animation) @@ -587,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, () => { @@ -701,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() } From 836da2304c83f4cf28004f083dd424bb10ad7d8a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:07:55 +0100 Subject: [PATCH 06/12] Fixing --- packages/motion-dom/src/render/VisualElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index 793729eaf3..8a8075891e 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -547,7 +547,7 @@ export abstract class VisualElement< ) { const { factory, keyframes, times, ease, duration } = value.accelerate - console.log(name, keyframes, times, ease) + const animation = new NativeAnimation({ element: this.current, name: key, From 9ebe1cf2ba6c65146e10e3450a9ae718d8100787 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:18:22 +0100 Subject: [PATCH 07/12] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64665f2eab..9fadab0905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.33.2] 2026-02-06 + +### Fixed + +- Improve detection of detached elements with vanilla layout animations. + ## [12.33.1] 2026-02-06 ### Fixed From 365b0ad6758188abed5c7fa37f6149fb0c806e1e Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:19:33 +0100 Subject: [PATCH 08/12] v12.33.2 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index 3d815866f6..47d905dbb4 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.33.2", "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.33.2", + "motion": "^12.33.2", + "motion-dom": "^12.33.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index f03646c9a0..21df097126 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.33.2", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.33.1", + "motion": "^12.33.2", "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..242ea04428 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.33.2", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.33.1", + "motion": "^12.33.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index a8baa0f678..79b3d5577a 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.33.2", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.33.1", + "framer-motion": "^12.33.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index d1f0925b41..91253fe45b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.33.1", + "version": "12.33.2", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 6da9f86767..54dc878e0f 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.33.2", "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.33.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 0bfb737ecf..82fca923b9 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.33.2", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 2519b7dd05..70e2dd545a 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.33.1", + "version": "12.33.2", "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.33.2", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index e8bc0312c2..e92caaa1be 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.33.2, 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.33.2 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.33.2 + motion: ^12.33.2 + motion-dom: ^12.33.2 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.33.2, 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.33.2, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.33.1 + framer-motion: ^12.33.2 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.33.2 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.33.2 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.33.2 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 From ec0446a7123973aa9897def82023ae6bd22b7618 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:27:23 +0100 Subject: [PATCH 09/12] Scroll acceleration --- Makefile | 2 +- dev/react/src/tests/scroll-accelerate.tsx | 9 +++++++ .../cypress/integration/scroll-accelerate.ts | 26 +++++++++++++------ 3 files changed, 28 insertions(+), 9 deletions(-) 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/react/src/tests/scroll-accelerate.tsx b/dev/react/src/tests/scroll-accelerate.tsx index 16fcf9cba4..4647f69a30 100644 --- a/dev/react/src/tests/scroll-accelerate.tsx +++ b/dev/react/src/tests/scroll-accelerate.tsx @@ -27,6 +27,15 @@ export const App = () => { id="chained" style={{ ...box, opacity: chainedOpacity, top: 110 }} /> + + {opacity.accelerate ? "true" : "false"} + + + {chainedOpacity.accelerate ? "true" : "false"} + + + {backgroundColor.accelerate ? "true" : "false"} + ) } diff --git a/packages/framer-motion/cypress/integration/scroll-accelerate.ts b/packages/framer-motion/cypress/integration/scroll-accelerate.ts index 115e25dc67..8027b4922f 100644 --- a/packages/framer-motion/cypress/integration/scroll-accelerate.ts +++ b/packages/framer-motion/cypress/integration/scroll-accelerate.ts @@ -1,21 +1,31 @@ describe("scroll timeline WAAPI acceleration", () => { - it("Creates WAAPI animation for accelerated property, not for non-accelerated", () => { + it("Propagates acceleration for direct useTransform from scroll", () => { cy.visit("?test=scroll-accelerate") .wait(200) - .get("#direct") + .get("#direct-accelerated") .should(([$el]: any) => { - // opacity is acceleratable, backgroundColor is not → 1 animation - expect($el.getAnimations().length).to.equal(1) + expect($el.innerText).to.equal("true") }) }) - it("Does not create WAAPI animation for chained useTransform", () => { + it("Propagates acceleration for non-acceleratable properties too", () => { cy.visit("?test=scroll-accelerate") .wait(200) - .get("#chained") + .get("#bg-accelerated") .should(([$el]: any) => { - // Chained useTransform should NOT accelerate - expect($el.getAnimations().length).to.equal(0) + // 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") }) }) }) From b24d9ff2748c1df939181e8c1120c01b2aa15421 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:38:25 +0100 Subject: [PATCH 10/12] latest --- dev/next/next-env.d.ts | 1 + 1 file changed, 1 insertion(+) 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. From 5f57695592707192f3c15af02127232d08c92d80 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:56:54 +0100 Subject: [PATCH 11/12] Updating changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fadab0905..2ed6ec09cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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 From 5adbf49c451ba1b8e91e6e17847cad520cafdc45 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 9 Feb 2026 12:57:10 +0100 Subject: [PATCH 12/12] v12.34.0 --- dev/html/package.json | 8 ++++---- dev/next/package.json | 4 ++-- dev/react-19/package.json | 4 ++-- dev/react/package.json | 4 ++-- lerna.json | 2 +- packages/framer-motion/package.json | 4 ++-- packages/motion-dom/package.json | 2 +- packages/motion/package.json | 4 ++-- yarn.lock | 22 +++++++++++----------- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dev/html/package.json b/dev/html/package.json index 47d905dbb4..071a7a2e6a 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.33.2", + "version": "12.34.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.33.2", - "motion": "^12.33.2", - "motion-dom": "^12.33.2", + "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/package.json b/dev/next/package.json index 21df097126..f5a4153628 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.33.2", + "version": "12.34.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.33.2", + "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 242ea04428..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.2", + "version": "12.34.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.33.2", + "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 79b3d5577a..b41af2a113 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.33.2", + "version": "12.34.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.33.2", + "framer-motion": "^12.34.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index 91253fe45b..9d284f3977 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.33.2", + "version": "12.34.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 54dc878e0f..a0f9f15e29 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.33.2", + "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.2", + "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 82fca923b9..42dd9491f9 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.33.2", + "version": "12.34.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion/package.json b/packages/motion/package.json index 70e2dd545a..e418097564 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.33.2", + "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.2", + "framer-motion": "^12.34.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index e92caaa1be..e9dd6863e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.33.2, 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.2 + 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.2 - motion: ^12.33.2 - motion-dom: ^12.33.2 + 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.2, 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.2, 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.2 + 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.2 + 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.2 + 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.2 + framer-motion: ^12.34.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0