diff --git a/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx b/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx index 02d71fd693..a27ed17bad 100644 --- a/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx +++ b/packages/framer-motion/src/animation/hooks/__tests__/use-animate.test.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom" import { render } from "@testing-library/react" import { useEffect } from "react" +import { MotionConfig } from "../../../components/MotionConfig" import { useAnimate } from "../use-animate" describe("useAnimate", () => { @@ -115,4 +116,39 @@ describe("useAnimate", () => { expect(frameCount).toEqual(3) }) + + test("Skips animations when MotionConfig skipAnimations is true", () => { + return new Promise((resolve) => { + const Component = () => { + const [scope, animate] = useAnimate() + + useEffect(() => { + const animation = animate( + scope.current, + { opacity: 0.5 }, + { duration: 1 } + ) + + // Animation should not be tracked in scope + expect(scope.animations.length).toBe(0) + + animation.then(() => { + // Element style should not be changed + expect(scope.current).not.toHaveStyle( + "opacity: 0.5;" + ) + resolve() + }) + }) + + return
+ } + + render( + + + + ) + }) + }) }) diff --git a/packages/framer-motion/src/animation/hooks/use-animate.ts b/packages/framer-motion/src/animation/hooks/use-animate.ts index e7b76d28dd..63f20170fc 100644 --- a/packages/framer-motion/src/animation/hooks/use-animate.ts +++ b/packages/framer-motion/src/animation/hooks/use-animate.ts @@ -1,10 +1,11 @@ "use client" -import { useMemo } from "react" -import { AnimationScope } from "motion-dom" +import { useContext, useMemo } from "react" +import { AnimationScope, GroupAnimationWithThen } from "motion-dom" import { useConstant } from "../../utils/use-constant" import { useUnmountEffect } from "../../utils/use-unmount-effect" import { useReducedMotionConfig } from "../../utils/reduced-motion/use-reduced-motion-config" +import { MotionConfigContext } from "../../context/MotionConfigContext" import { createScopedAnimate } from "../animate" export function useAnimate() { @@ -13,11 +14,15 @@ export function useAnimate() { animations: [], })) + const { skipAnimations } = useContext(MotionConfigContext) const reduceMotion = useReducedMotionConfig() ?? undefined const animate = useMemo( - () => createScopedAnimate({ scope, reduceMotion }), - [scope, reduceMotion] + () => + skipAnimations + ? createNoopAnimate(scope) + : createScopedAnimate({ scope, reduceMotion }), + [scope, reduceMotion, skipAnimations] ) useUnmountEffect(() => { @@ -27,3 +32,16 @@ export function useAnimate() { return [scope, animate] as [AnimationScope, typeof animate] } + +/** + * When skipAnimations is true, return an animate function that resolves + * immediately without creating any WAAPI animations. This prevents + * browsers (particularly WebKit) from reporting running animations via + * element.getAnimations(), which can break tools like Playwright that + * check element stability. + */ +function createNoopAnimate(scope: AnimationScope) { + return ((..._args: any[]) => { + return new GroupAnimationWithThen([]) + }) as ReturnType +}