Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -115,4 +116,39 @@ describe("useAnimate", () => {

expect(frameCount).toEqual(3)
})

test("Skips animations when MotionConfig skipAnimations is true", () => {
return new Promise<void>((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;"
)
Comment on lines +136 to +139
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that when skipAnimations is true the element style is not updated. However, skipAnimations is documented/covered elsewhere as "values will be set instantly" (see MotionConfigContext and components/MotionConfig/__tests__/index.test.tsx). For useAnimate, it would be more consistent to apply the final target state synchronously (without creating WAAPI animations) and still resolve immediately, and update this expectation accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +139
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Test asserts the wrong behavior

This assertion encodes the inconsistency flagged in the implementation: the test expects the element NOT to have opacity: 0.5 after the animation completes. But skipAnimations in the declarative path means "apply the final value instantly, skip the transition" — not "discard the animation entirely". If the implementation is corrected to apply values instantly (consistent with the rest of the library), this assertion should become toHaveStyle("opacity: 0.5;") and the scope.animations.length check on line 133 should similarly reflect however many instant-animations the corrected path creates.

resolve()
})
})

return <div ref={scope} />
}

render(
<MotionConfig skipAnimations>
<Component />
</MotionConfig>
)
})
})
})
26 changes: 22 additions & 4 deletions packages/framer-motion/src/animation/hooks/use-animate.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Element = any>() {
Expand All @@ -13,11 +14,15 @@ export function useAnimate<T extends Element = any>() {
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(() => {
Expand All @@ -27,3 +32,16 @@ export function useAnimate<T extends Element = any>() {

return [scope, animate] as [AnimationScope<T>, 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<T extends Element>(scope: AnimationScope<T>) {
return ((..._args: any[]) => {
return new GroupAnimationWithThen([])
}) as ReturnType<typeof createScopedAnimate>
Comment on lines +43 to +46
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createNoopAnimate returns new GroupAnimationWithThen([]). In motion-dom, GroupAnimation assumes at least one child animation for several getters (e.g. time, speed, state, startTime) and will throw when animations is empty (GroupAnimation.ts uses this.animations[0][propName]). This makes the returned controls unsafe to interact with when skipAnimations is true. Consider returning a dedicated no-op AnimationPlaybackControlsWithThen implementation (with safe default fields and finished resolved) or a GroupAnimationWithThen containing a single no-op controls object. Also note scope is currently unused here and will be flagged by the repo's noUnusedParameters TS setting unless renamed/removed.

Copilot uses AI. Check for mistakes.
}
Comment on lines +43 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Noop animate doesn't apply target values — inconsistent with declarative path

createNoopAnimate resolves immediately without writing any values to the element. When skipAnimations is true, the declarative path (animateMotionValuemakeAnimationInstantframe.update(() => onUpdate(finalKeyframe))) and MotionGlobalConfig.skipAnimations both apply the final value instantly. The imperative noop skips it entirely, leaving the element frozen in its current state.

Concrete failure: calling animate(el, { opacity: 0 }) inside a <MotionConfig skipAnimations> subtree will not make the element invisible. An exit animation that fades to 0 leaves the element fully visible; a slide animation that moves the element leaves it in place. The test even asserts this broken state: expect(scope.current).not.toHaveStyle("opacity: 0.5;") — the declarative equivalent would set opacity to 0.5 instantly.

A correct implementation should call createScopedAnimate with options that force duration:0 (or temporarily set MotionGlobalConfig.skipAnimations = true), so the underlying animateMotionValue machinery applies the final keyframe via its fast-exit path.

Comment on lines +43 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 scope parameter is never used

The scope argument accepted by createNoopAnimate is unused inside the returned function. Since the noop discards all args via ..._args, there is no need for the typed parameter — it only clutters the signature and may mislead future readers into thinking the scope is being modified.

Suggested change
function createNoopAnimate<T extends Element>(scope: AnimationScope<T>) {
return ((..._args: any[]) => {
return new GroupAnimationWithThen([])
}) as ReturnType<typeof createScopedAnimate>
}
function createNoopAnimate<T extends Element>(_scope: AnimationScope<T>) {
return ((..._args: any[]) => {
return new GroupAnimationWithThen([])
}) as ReturnType<typeof createScopedAnimate>
}

Loading