From 0fb160f50a89d7e1c4c55937463b97f3e93dbda5 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 16 Apr 2026 10:20:28 +0200 Subject: [PATCH] fix: useAnimate respects MotionConfig.skipAnimations The imperative useAnimate hook only checked reducedMotion via useReducedMotionConfig(), ignoring MotionConfig's skipAnimations prop. This meant WAAPI animations were still created even when skipAnimations was true, just with reduced timing. This is problematic for e2e testing tools like Playwright, which call element.getAnimations() for stability checks. WebKit reports these zero-duration WAAPI animations as running, causing timeouts. When skipAnimations is true, the returned animate function now resolves immediately via an empty GroupAnimationWithThen without creating any WAAPI animations, consistent with how declarative animations behave when skipAnimations is set on MotionConfig. Fixes #3679 --- .../hooks/__tests__/use-animate.test.tsx | 36 +++++++++++++++++++ .../src/animation/hooks/use-animate.ts | 26 +++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) 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 +}