diff --git a/CHANGELOG.md b/CHANGELOG.md index b03018ba15..d697b28104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.30.0] 2026-02-02 + +### Added + +- `MotionConfig`: Add `skipAnimations` option. + +### Fixed + +- `animate`: Prevent error when calling `stop()` on removed elements. +- `animateLayout`: Fixing shared element animations when `animate` called before `animateLayout`. + ## [12.29.3] 2026-02-02 ### Fixed diff --git a/dev/html/package.json b/dev/html/package.json index 4ce792b5d4..d58ab3e796 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.29.3", + "version": "12.30.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.29.3", - "motion": "^12.29.3", - "motion-dom": "^12.29.2", + "framer-motion": "^12.30.0", + "motion": "^12.30.0", + "motion-dom": "^12.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/html/public/animate-layout/modal-open-after-animate.html b/dev/html/public/animate-layout/modal-open-after-animate.html new file mode 100644 index 0000000000..ce99e5f8ff --- /dev/null +++ b/dev/html/public/animate-layout/modal-open-after-animate.html @@ -0,0 +1,243 @@ + + + + + + + + + + + + diff --git a/dev/html/public/playwright/animate/mini.html b/dev/html/public/playwright/animate/mini.html index 25fd202fdb..272e0e306c 100644 --- a/dev/html/public/playwright/animate/mini.html +++ b/dev/html/public/playwright/animate/mini.html @@ -35,6 +35,7 @@
autoplay
time
custom easing
+
stop after remove
diff --git a/dev/html/src/imports/animate-layout.js b/dev/html/src/imports/animate-layout.js index 484c5f71f0..700c820a18 100644 --- a/dev/html/src/imports/animate-layout.js +++ b/dev/html/src/imports/animate-layout.js @@ -2,6 +2,7 @@ import { LayoutAnimationBuilder, frame, parseAnimateLayoutArgs, + animate, } from "framer-motion/dom" export function unstable_animateLayout( @@ -22,4 +23,5 @@ window.AnimateLayout = { animateLayout: unstable_animateLayout, LayoutAnimationBuilder, frame, + animate, } diff --git a/dev/next/package.json b/dev/next/package.json index d54351379c..215b096d23 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.29.3", + "version": "12.30.0", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.29.3", + "motion": "^12.30.0", "next": "15.4.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 d45f0e04f9..c2c1bbff12 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.29.3", + "version": "12.30.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.29.3", + "motion": "^12.30.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 91461b767f..7b7e188e8b 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.29.3", + "version": "12.30.0", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.29.3", + "framer-motion": "^12.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/lerna.json b/lerna.json index c36eefb19a..8342924aa4 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.29.3", + "version": "12.30.0", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index b7090502f4..35a130ed29 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -1 +1 @@ -["app-store-a-b-a.html","basic-position-change.html","interrupt-animation.html","modal-open-close-interrupt.html","modal-open-close-open-interrupt.html","modal-open-close-open.html","modal-open-close.html","modal-open-opacity.html","modal-open.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-a-ab-a.html","shared-element-a-b-a-replace.html","shared-element-a-b-a-reuse.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-nested-children-bottom.html","shared-element-nested-children.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file +["app-store-a-b-a.html","basic-position-change.html","interrupt-animation.html","modal-open-after-animate.html","modal-open-close-interrupt.html","modal-open-close-open-interrupt.html","modal-open-close-open.html","modal-open-close.html","modal-open-opacity.html","modal-open.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-a-ab-a.html","shared-element-a-b-a-replace.html","shared-element-a-b-a-reuse.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-nested-children-bottom.html","shared-element-nested-children.html","shared-element-no-crossfade.html","shared-multiple-elements.html"] \ No newline at end of file diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 4b1d17b012..a73f08dde4 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.29.3", + "version": "12.30.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.29.2", + "motion-dom": "^12.30.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, diff --git a/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx b/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx index 0e0fc24dad..34e549285c 100644 --- a/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx +++ b/packages/framer-motion/src/components/MotionConfig/__tests__/index.test.tsx @@ -75,3 +75,107 @@ describe("reducedMotion", () => { expect(result[1]).not.toEqual(1) }) }) + +describe("skipAnimations", () => { + test("skipAnimations makes all animations complete instantly", async () => { + const result = await new Promise<[number, number]>(async (resolve) => { + const x = motionValue(0) + const opacity = motionValue(0) + const Component = () => { + return ( + + + + ) + } + + const { rerender } = render() + rerender() + + await nextFrame() + + resolve([x.get(), opacity.get()]) + }) + + // Both transform and non-transform values should complete instantly + expect(result[0]).toEqual(100) + expect(result[1]).toEqual(1) + }) + + test("skipAnimations=false does not skip animations", async () => { + const result = await new Promise<[number, number]>(async (resolve) => { + const x = motionValue(0) + const opacity = motionValue(0) + const Component = () => { + return ( + + + + ) + } + + const { rerender } = render() + rerender() + + await nextFrame() + + resolve([x.get(), opacity.get()]) + }) + + // Values should still be animating (not yet at final value) + expect(result[0]).not.toEqual(100) + expect(result[1]).not.toEqual(1) + }) + + test("skipAnimations is scoped to component tree", async () => { + const result = await new Promise<[number, number, number, number]>( + async (resolve) => { + const x1 = motionValue(0) + const opacity1 = motionValue(0) + const x2 = motionValue(0) + const opacity2 = motionValue(0) + + const Component = () => { + return ( + <> + + + + + + ) + } + + const { rerender } = render() + rerender() + + await nextFrame() + + resolve([x1.get(), opacity1.get(), x2.get(), opacity2.get()]) + } + ) + + // Inside MotionConfig with skipAnimations - should be instant + expect(result[0]).toEqual(100) + expect(result[1]).toEqual(1) + // Outside MotionConfig - should still be animating + expect(result[2]).not.toEqual(100) + expect(result[3]).not.toEqual(1) + }) +}) diff --git a/packages/framer-motion/src/components/MotionConfig/index.tsx b/packages/framer-motion/src/components/MotionConfig/index.tsx index d669225c3b..ead6133680 100644 --- a/packages/framer-motion/src/components/MotionConfig/index.tsx +++ b/packages/framer-motion/src/components/MotionConfig/index.tsx @@ -59,6 +59,7 @@ export function MotionConfig({ JSON.stringify(config.transition), config.transformPagePoint, config.reducedMotion, + config.skipAnimations, ] ) diff --git a/packages/framer-motion/src/context/MotionConfigContext.tsx b/packages/framer-motion/src/context/MotionConfigContext.tsx index d8b7b37f25..a527e48f72 100644 --- a/packages/framer-motion/src/context/MotionConfigContext.tsx +++ b/packages/framer-motion/src/context/MotionConfigContext.tsx @@ -44,6 +44,14 @@ export interface MotionConfigContext { * @public */ nonce?: string + + /** + * If true, all animations will be skipped and values will be set instantly. + * Useful for E2E tests and visual regression testing. + * + * @public + */ + skipAnimations?: boolean } /** diff --git a/packages/framer-motion/src/motion/utils/use-visual-element.ts b/packages/framer-motion/src/motion/utils/use-visual-element.ts index cd5f081516..5fe5e3a2a1 100644 --- a/packages/framer-motion/src/motion/utils/use-visual-element.ts +++ b/packages/framer-motion/src/motion/utils/use-visual-element.ts @@ -40,7 +40,9 @@ export function useVisualElement< const { visualElement: parent } = useContext(MotionContext) const lazyContext = useContext(LazyContext) const presenceContext = useContext(PresenceContext) - const reducedMotionConfig = useContext(MotionConfigContext).reducedMotion + const motionConfig = useContext(MotionConfigContext) + const reducedMotionConfig = motionConfig.reducedMotion + const skipAnimations = motionConfig.skipAnimations const visualElementRef = useRef { props: MotionProps blockInitialAnimation?: boolean reducedMotionConfig?: ReducedMotionConfig + /** + * If true, all animations will be skipped and values will be set instantly. + * Useful for E2E tests and visual regression testing. + */ + skipAnimations?: boolean /** * Explicit override for SVG detection. When true, uses SVG rendering; * when false, uses HTML rendering. If undefined, auto-detects. diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index a68da94d35..00e2bbfbc0 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.29.2", + "version": "12.30.0", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index 8ee336901a..8a78aacec2 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -178,7 +178,8 @@ export class NativeAnimation * while deferring the commit until the next animation frame. */ protected commitStyles() { - if (!this.isPseudoElement) { + const element = this.options?.element + if (!this.isPseudoElement && element?.isConnected) { this.animation.commitStyles?.() } } diff --git a/packages/motion-dom/src/animation/interfaces/motion-value.ts b/packages/motion-dom/src/animation/interfaces/motion-value.ts index 319c2713cb..db9a80f670 100644 --- a/packages/motion-dom/src/animation/interfaces/motion-value.ts +++ b/packages/motion-dom/src/animation/interfaces/motion-value.ts @@ -99,7 +99,8 @@ export const animateMotionValue = if ( MotionGlobalConfig.instantAnimations || - MotionGlobalConfig.skipAnimations + MotionGlobalConfig.skipAnimations || + element?.shouldSkipAnimations ) { shouldSkip = true makeAnimationInstant(options) diff --git a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts index 3a802fd021..929c16f9dc 100644 --- a/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts +++ b/packages/motion-dom/src/layout/LayoutAnimationBuilder.ts @@ -330,6 +330,10 @@ function getOrCreateRecord( if (!visualElement.current) { visualElement.mount(element as HTMLElement) + } else if (!visualElement.projection.instance) { + // Mount projection if VisualElement is already mounted but projection isn't + // This happens when animate() was called before animateLayout() + visualElement.projection.mount(element as HTMLElement) } if (!existing) { diff --git a/packages/motion-dom/src/render/VisualElement.ts b/packages/motion-dom/src/render/VisualElement.ts index 2106ec7741..deb8877ff8 100644 --- a/packages/motion-dom/src/render/VisualElement.ts +++ b/packages/motion-dom/src/render/VisualElement.ts @@ -252,6 +252,12 @@ export abstract class VisualElement< */ shouldReduceMotion: boolean | null = null + /** + * Decides whether animations should be skipped for this VisualElement. + * Useful for E2E tests and visual regression testing. + */ + shouldSkipAnimations: boolean = false + /** * Normally, if a component is controlled by a parent's variants, it can * rely on that ancestor to trigger animations further down the tree. @@ -320,6 +326,11 @@ export abstract class VisualElement< */ private reducedMotionConfig: ReducedMotionConfig | undefined + /** + * A reference to the skipAnimations config passed to the VisualElement's host React component. + */ + private skipAnimationsConfig: boolean | undefined + /** * On mount, this will be hydrated with a callback to disconnect * this visual element from its parent on unmount. @@ -366,6 +377,7 @@ export abstract class VisualElement< props, presenceContext, reducedMotionConfig, + skipAnimations, blockInitialAnimation, visualState, }: VisualElementOptions, @@ -381,6 +393,7 @@ export abstract class VisualElement< this.presenceContext = presenceContext this.depth = parent ? parent.depth + 1 : 0 this.reducedMotionConfig = reducedMotionConfig + this.skipAnimationsConfig = skipAnimations this.options = options this.blockInitialAnimation = Boolean(blockInitialAnimation) @@ -453,6 +466,11 @@ export abstract class VisualElement< ) } + /** + * Set whether animations should be skipped based on the config. + */ + this.shouldSkipAnimations = this.skipAnimationsConfig ?? false + this.parent?.addChild(this) this.update(this.props, this.presenceContext) diff --git a/packages/motion-dom/src/render/types.ts b/packages/motion-dom/src/render/types.ts index 23e091391f..137de259f3 100644 --- a/packages/motion-dom/src/render/types.ts +++ b/packages/motion-dom/src/render/types.ts @@ -66,6 +66,14 @@ export interface MotionConfigContextProps { * @public */ nonce?: string + + /** + * If true, all animations will be skipped and values will be set instantly. + * Useful for E2E tests and visual regression testing. + * + * @public + */ + skipAnimations?: boolean } export interface VisualState<_Instance, RenderState> { @@ -81,6 +89,11 @@ export interface VisualElementOptions { props: MotionNodeOptions blockInitialAnimation?: boolean reducedMotionConfig?: ReducedMotionConfig + /** + * If true, all animations will be skipped and values will be set instantly. + * Useful for E2E tests and visual regression testing. + */ + skipAnimations?: boolean /** * Explicit override for SVG detection. When true, uses SVG rendering; * when false, uses HTML rendering. If undefined, auto-detects. diff --git a/packages/motion/package.json b/packages/motion/package.json index fda38ed78c..d3d990aba0 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.29.3", + "version": "12.30.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.29.3", + "framer-motion": "^12.30.0", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/tests/animate-layout/animate-layout.spec.ts b/tests/animate-layout/animate-layout.spec.ts new file mode 100644 index 0000000000..29ea2c02fa --- /dev/null +++ b/tests/animate-layout/animate-layout.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test" + +test.describe("animateLayout()", () => { + test("shared element animation works when animate() is called before animateLayout()", async ({ + page, + }) => { + await page.goto("animate-layout/modal-open-after-animate.html") + + // Wait for the test script to run + await page.waitForTimeout(500) + + // Check that no elements have data-layout-correct="false" (which would indicate test failure) + const failedElements = await page + .locator('[data-layout-correct="false"]') + .count() + expect(failedElements).toBe(0) + }) + + test("original modal-open test still works", async ({ page }) => { + await page.goto("animate-layout/modal-open.html") + + // Wait for the test script to run + await page.waitForTimeout(500) + + // Check that no elements have data-layout-correct="false" + const failedElements = await page + .locator('[data-layout-correct="false"]') + .count() + expect(failedElements).toBe(0) + }) +}) diff --git a/tests/animate/mini.spec.ts b/tests/animate/mini.spec.ts index a7a20deb64..1735a9ab62 100644 --- a/tests/animate/mini.spec.ts +++ b/tests/animate/mini.spec.ts @@ -62,4 +62,11 @@ test.describe("animateMini", () => { const element = page.locator("#custom-easing") await expect(element).toHaveCSS("opacity", "0.25") }) + + test("stop() does not crash when element is removed from DOM", async ({ + page, + }) => { + const element = page.locator("#stop-after-remove-result") + await expect(element).toHaveText("complete") + }) }) diff --git a/yarn.lock b/yarn.lock index e08f109223..1a3f3495c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.29.3, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.30.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.29.2 + motion-dom: ^12.30.0 motion-utils: ^12.29.2 three: 0.137.0 tslib: ^2.4.0 @@ -8192,9 +8192,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.29.3 - motion: ^12.29.3 - motion-dom: ^12.29.2 + framer-motion: ^12.30.0 + motion: ^12.30.0 + motion-dom: ^12.30.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.29.2, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.30.0, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.29.3, motion@workspace:packages/motion": +"motion@^12.30.0, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.29.3 + framer-motion: ^12.30.0 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.29.3 + motion: ^12.30.0 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.29.3 + motion: ^12.30.0 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,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.29.3 + framer-motion: ^12.30.0 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0