diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed6ec09cd..acbc9eac71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,17 @@ 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 +## [12.34.1] 2026-02-17 ### Fixed +- `useScroll`: Ensure animations aren't hardware accelerated when `target` is set. +- Improve animatable `"none"` generation for mask values. + +## [12.34.0] 2026-02-09 + +### Added + - `useScroll`: Hardware accelerated animations. ## [12.33.2] 2026-02-06 diff --git a/dev/html/package.json b/dev/html/package.json index 071a7a2e6a..0e4bd03c0d 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.34.0", + "version": "12.34.1", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.34.0", - "motion": "^12.34.0", - "motion-dom": "^12.34.0", + "framer-motion": "^12.34.1", + "motion": "^12.34.1", + "motion-dom": "^12.34.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index f5a4153628..b611c65b36 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.34.0", + "version": "12.34.1", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.34.0", + "motion": "^12.34.1", "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 3ee71c3b14..2e0260ddc8 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.34.0", + "version": "12.34.1", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.34.0", + "motion": "^12.34.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index b41af2a113..df5051792d 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.34.0", + "version": "12.34.1", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.34.0", + "framer-motion": "^12.34.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/react/src/tests/scroll-target-transform.tsx b/dev/react/src/tests/scroll-target-transform.tsx new file mode 100644 index 0000000000..8a078255fb --- /dev/null +++ b/dev/react/src/tests/scroll-target-transform.tsx @@ -0,0 +1,44 @@ +import { motion, useScroll, useTransform } from "framer-motion" +import * as React from "react" +import { useRef } from "react" + +export const App = () => { + const targetRef = useRef(null) + const { scrollYProgress } = useScroll({ + target: targetRef, + offset: ["start end", "end start"], + }) + + const opacity = useTransform(scrollYProgress, [0, 1], [1, 0]) + const y = useTransform(scrollYProgress, [0, 1], [0, -100]) + + return ( + <> +
+
+ +
+
+
+ + {scrollYProgress.accelerate ? "true" : "false"} + + + ) +} + +const spacer = { height: "100vh" } +const targetStyle: React.CSSProperties = { + height: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", +} +const box: React.CSSProperties = { + width: 100, + height: 100, + background: "red", +} diff --git a/lerna.json b/lerna.json index 9d284f3977..147512e804 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.34.0", + "version": "12.34.1", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/cypress/integration/scroll-accelerate.ts b/packages/framer-motion/cypress/integration/scroll-accelerate.ts index 8027b4922f..5efaff464f 100644 --- a/packages/framer-motion/cypress/integration/scroll-accelerate.ts +++ b/packages/framer-motion/cypress/integration/scroll-accelerate.ts @@ -4,7 +4,10 @@ describe("scroll timeline WAAPI acceleration", () => { .wait(200) .get("#direct-accelerated") .should(([$el]: any) => { - expect($el.innerText).to.equal("true") + const expected = (window as any).ScrollTimeline + ? "true" + : "false" + expect($el.innerText).to.equal(expected) }) }) @@ -16,7 +19,10 @@ describe("scroll timeline WAAPI acceleration", () => { // backgroundColor gets accelerate config propagated, // but VisualElement skips WAAPI creation since it's // not in the acceleratedValues set - expect($el.innerText).to.equal("true") + const expected = (window as any).ScrollTimeline + ? "true" + : "false" + expect($el.innerText).to.equal(expected) }) }) diff --git a/packages/framer-motion/cypress/integration/scroll-target-transform.ts b/packages/framer-motion/cypress/integration/scroll-target-transform.ts new file mode 100644 index 0000000000..915b5b35ed --- /dev/null +++ b/packages/framer-motion/cypress/integration/scroll-target-transform.ts @@ -0,0 +1,31 @@ +describe("useScroll with target does not set accelerate", () => { + it("Does not set accelerate when target is provided", () => { + cy.visit("?test=scroll-target-transform") + .wait(200) + .get("#has-accelerate") + .should(([$el]: any) => { + expect($el.innerText).to.equal("false") + }) + }) + + it("Opacity updates via useTransform when scrolling", () => { + cy.visit("?test=scroll-target-transform") + .wait(200) + .get("#target") + .should(([$el]: any) => { + // Before scrolling, opacity should be near initial value + const initialOpacity = parseFloat( + getComputedStyle($el).opacity + ) + expect(initialOpacity).to.be.greaterThan(0) + }) + cy.scrollTo("bottom", { duration: 300 }) + .wait(200) + .get("#target") + .should(([$el]: any) => { + // After scrolling, opacity should have changed + const opacity = parseFloat(getComputedStyle($el).opacity) + expect(opacity).to.be.lessThan(1) + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index a0f9f15e29..f15fc7c908 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.34.0", + "version": "12.34.1", "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.34.0", + "motion-dom": "^12.34.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/framer-motion/src/render/dom/scroll/utils/can-use-native-timeline.ts b/packages/framer-motion/src/render/dom/scroll/utils/can-use-native-timeline.ts new file mode 100644 index 0000000000..9e3c04c1d2 --- /dev/null +++ b/packages/framer-motion/src/render/dom/scroll/utils/can-use-native-timeline.ts @@ -0,0 +1,7 @@ +import { supportsScrollTimeline } from "motion-dom" + +export function canUseNativeTimeline(target?: Element) { + return ( + typeof window !== "undefined" && !target && supportsScrollTimeline() + ) +} diff --git a/packages/framer-motion/src/render/dom/scroll/utils/get-timeline.ts b/packages/framer-motion/src/render/dom/scroll/utils/get-timeline.ts index 48eb5787d3..97a299c42e 100644 --- a/packages/framer-motion/src/render/dom/scroll/utils/get-timeline.ts +++ b/packages/framer-motion/src/render/dom/scroll/utils/get-timeline.ts @@ -1,6 +1,7 @@ -import { ProgressTimeline, supportsScrollTimeline } from "motion-dom" +import { ProgressTimeline } from "motion-dom" import { scrollInfo } from "../track" import { ScrollOptionsWithDefaults } from "../types" +import { canUseNativeTimeline } from "./can-use-native-timeline" declare global { interface Window { @@ -50,7 +51,7 @@ export function getTimeline({ if (!targetCache[axisKey]) { targetCache[axisKey] = - !options.target && supportsScrollTimeline() + canUseNativeTimeline(options.target) ? new ScrollTimeline({ source: container, axis } as any) : scrollTimelineFallback({ container, ...options }) } diff --git a/packages/framer-motion/src/value/__tests__/use-scroll.test.tsx b/packages/framer-motion/src/value/__tests__/use-scroll.test.tsx new file mode 100644 index 0000000000..24c1fbec89 --- /dev/null +++ b/packages/framer-motion/src/value/__tests__/use-scroll.test.tsx @@ -0,0 +1,88 @@ +import { supportsFlags } from "motion-dom" +import { useRef } from "react" +import { render } from "../../jest.setup" +import { useScroll } from "../use-scroll" +import { useTransform } from "../use-transform" + +describe("useScroll accelerate", () => { + afterEach(() => { + supportsFlags.scrollTimeline = undefined + }) + + test("sets accelerate on progress values when ScrollTimeline is supported and no target", () => { + supportsFlags.scrollTimeline = true + + let accelerateX: any + let accelerateY: any + + const Component = () => { + const { scrollXProgress, scrollYProgress } = useScroll() + accelerateX = scrollXProgress.accelerate + accelerateY = scrollYProgress.accelerate + return null + } + + render() + + expect(accelerateX).toBeDefined() + expect(accelerateY).toBeDefined() + }) + + test("does not set accelerate when target ref is provided", () => { + supportsFlags.scrollTimeline = true + + let accelerateX: any + let accelerateY: any + + const Component = () => { + const target = useRef(null) + const { scrollXProgress, scrollYProgress } = useScroll({ + target, + }) + accelerateX = scrollXProgress.accelerate + accelerateY = scrollYProgress.accelerate + return
+ } + + render() + + expect(accelerateX).toBeUndefined() + expect(accelerateY).toBeUndefined() + }) + + test("does not set accelerate when ScrollTimeline is not supported", () => { + supportsFlags.scrollTimeline = false + + let accelerateX: any + let accelerateY: any + + const Component = () => { + const { scrollXProgress, scrollYProgress } = useScroll() + accelerateX = scrollXProgress.accelerate + accelerateY = scrollYProgress.accelerate + return null + } + + render() + + expect(accelerateX).toBeUndefined() + expect(accelerateY).toBeUndefined() + }) + + test("propagates accelerate through useTransform", () => { + supportsFlags.scrollTimeline = true + + let transformAccelerate: any + + const Component = () => { + const { scrollYProgress } = useScroll() + const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]) + transformAccelerate = opacity.accelerate + return null + } + + render() + + expect(transformAccelerate).toBeDefined() + }) +}) diff --git a/packages/framer-motion/src/value/use-scroll.ts b/packages/framer-motion/src/value/use-scroll.ts index 0efd38254f..f596096bc8 100644 --- a/packages/framer-motion/src/value/use-scroll.ts +++ b/packages/framer-motion/src/value/use-scroll.ts @@ -5,6 +5,7 @@ import { invariant } from "motion-utils" import { RefObject, useCallback, useEffect, useRef } from "react" import { scroll } from "../render/dom/scroll" import { ScrollInfoOptions } from "../render/dom/scroll/types" +import { canUseNativeTimeline } from "../render/dom/scroll/utils/can-use-native-timeline" import { useConstant } from "../utils/use-constant" import { useIsomorphicLayoutEffect } from "../utils/use-isomorphic-effect" @@ -26,6 +27,21 @@ const isRefPending = (ref?: RefObject) => { return !ref.current } +function makeAccelerateConfig( + axis: "x" | "y", + options: Omit, + container?: Element +) { + return { + factory: (animation: AnimationPlaybackControls) => + scroll(animation, { ...options, axis, container }), + times: [0, 1], + keyframes: [0, 1], + ease: (v: number) => v, + duration: 1, + } +} + export function useScroll({ container, target, @@ -33,31 +49,18 @@ export function useScroll({ }: 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, + if (!target && canUseNativeTimeline()) { + const resolvedContainer = container?.current || undefined + values.scrollXProgress.accelerate = makeAccelerateConfig( + "x", + options, + resolvedContainer + ) + values.scrollYProgress.accelerate = makeAccelerateConfig( + "y", + options, + resolvedContainer + ) } const scrollAnimation = useRef(null) diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index 42dd9491f9..406b4128ba 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.34.0", + "version": "12.34.1", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/utils/supports/scroll-timeline.ts b/packages/motion-dom/src/utils/supports/scroll-timeline.ts index ddf88b31e1..a949da5c4f 100644 --- a/packages/motion-dom/src/utils/supports/scroll-timeline.ts +++ b/packages/motion-dom/src/utils/supports/scroll-timeline.ts @@ -1,5 +1,5 @@ -import { memo } from "motion-utils" import { ProgressTimeline } from "../.." +import { memoSupports } from "./memo" declare global { interface Window { @@ -15,6 +15,7 @@ declare class ScrollTimeline implements ProgressTimeline { cancel?: VoidFunction } -export const supportsScrollTimeline = /* @__PURE__ */ memo( - () => window.ScrollTimeline !== undefined +export const supportsScrollTimeline = /* @__PURE__ */ memoSupports( + () => window.ScrollTimeline !== undefined, + "scrollTimeline" ) diff --git a/packages/motion-dom/src/value/types/__tests__/index.test.ts b/packages/motion-dom/src/value/types/__tests__/index.test.ts index 50e03a83bd..c28712245a 100644 --- a/packages/motion-dom/src/value/types/__tests__/index.test.ts +++ b/packages/motion-dom/src/value/types/__tests__/index.test.ts @@ -4,6 +4,7 @@ import { hsla } from "../color/hsla" import { rgba, rgbUnit } from "../color/rgba" import { complex } from "../complex" import { filter } from "../complex/filter" +import { mask } from "../complex/mask" import { alpha } from "../numbers" import { degrees, percent, progressPercentage, px } from "../numbers/units" import { colorRegex } from "../utils/color-regex" @@ -575,3 +576,35 @@ describe("filter", () => { ) }) }) + +describe("mask", () => { + it("should create an animatableNone with opaque colors", () => { + expect( + mask.getAnimatableNone( + "linear-gradient(0deg, rgba(0,0,0,0) 0%, rgb(0,0,0) 100%)" + ) + ).toBe( + "linear-gradient(0deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 1) 0%)" + ) + }) + + it("should zero numbers but keep colors opaque with hex", () => { + expect( + mask.getAnimatableNone( + "linear-gradient(180deg, #000000 0%, #000000 50%, rgba(0,0,0,0) 100%)" + ) + ).toBe( + "linear-gradient(0deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 1) 0%)" + ) + }) + + it("should handle radial gradients", () => { + expect( + mask.getAnimatableNone( + "radial-gradient(circle at 50% 25%, rgba(0,0,0,1), rgba(0,0,0,0))" + ) + ).toBe( + "radial-gradient(circle at 0% 0%, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1))" + ) + }) +}) diff --git a/packages/motion-dom/src/value/types/complex/mask.ts b/packages/motion-dom/src/value/types/complex/mask.ts new file mode 100644 index 0000000000..e03044044c --- /dev/null +++ b/packages/motion-dom/src/value/types/complex/mask.ts @@ -0,0 +1,15 @@ +import { complex } from "." +import { AnyResolvedKeyframe } from "../../../animation/types" + +export const mask = { + ...complex, + getAnimatableNone: (v: AnyResolvedKeyframe) => { + const parsed = complex.parse(v) + const transformer = complex.createTransformer(v) + return transformer( + parsed.map((v) => + typeof v === "number" ? 0 : typeof v === "object" ? { ...v, alpha: 1 } : v + ) + ) + }, +} diff --git a/packages/motion-dom/src/value/types/maps/defaults.ts b/packages/motion-dom/src/value/types/maps/defaults.ts index 3692694980..fd50cb763b 100644 --- a/packages/motion-dom/src/value/types/maps/defaults.ts +++ b/packages/motion-dom/src/value/types/maps/defaults.ts @@ -1,5 +1,6 @@ import { color } from "../color" import { filter } from "../complex/filter" +import { mask } from "../complex/mask" import { numberValueTypes } from "./number" import { ValueTypeMap } from "./types" @@ -24,6 +25,8 @@ export const defaultValueTypes: ValueTypeMap = { borderLeftColor: color, filter, WebkitFilter: filter, + mask, + WebkitMask: mask, } /** diff --git a/packages/motion-dom/src/value/types/utils/animatable-none.ts b/packages/motion-dom/src/value/types/utils/animatable-none.ts index 16443ff670..0f768db3df 100644 --- a/packages/motion-dom/src/value/types/utils/animatable-none.ts +++ b/packages/motion-dom/src/value/types/utils/animatable-none.ts @@ -1,10 +1,13 @@ import { complex } from "../complex" import { filter } from "../complex/filter" +import { mask } from "../complex/mask" import { getDefaultValueType } from "../maps/defaults" +const customTypes = /*@__PURE__*/ new Set([filter, mask]) + export function getAnimatableNone(key: string, value: string) { let defaultValueType = getDefaultValueType(key) - if (defaultValueType !== filter) defaultValueType = complex + if (!customTypes.has(defaultValueType as any)) defaultValueType = complex // If value is not recognised as animatable, ie "none", create an animatable version origin based on the target return defaultValueType.getAnimatableNone ? defaultValueType.getAnimatableNone(value) diff --git a/packages/motion/package.json b/packages/motion/package.json index e418097564..6b6185139c 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.34.0", + "version": "12.34.1", "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.34.0", + "framer-motion": "^12.34.1", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index e9dd6863e7..77f2a9217f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7437,14 +7437,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.34.0, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.34.1, 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.34.0 + motion-dom: ^12.34.1 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.34.0 - motion: ^12.34.0 - motion-dom: ^12.34.0 + framer-motion: ^12.34.1 + motion: ^12.34.1 + motion-dom: ^12.34.1 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.34.0, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.34.1, 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.34.0, motion@workspace:packages/motion": +"motion@^12.34.1, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.34.0 + framer-motion: ^12.34.1 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.34.0 + motion: ^12.34.1 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.34.0 + motion: ^12.34.1 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.34.0 + framer-motion: ^12.34.1 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0