Skip to content

useAnimate does not respect MotionConfig.skipAnimations #3679

@jthrilly

Description

@jthrilly

Description

The imperative useAnimate hook does not respect MotionConfig's skipAnimations prop. It only checks reducedMotion via useReducedMotionConfig(), while declarative animations (initial/animate/exit) respect both skipAnimations and reducedMotion through the VisualElement class.

This means that even with <MotionConfig skipAnimations={true}>, useAnimate still creates WAAPI animations — they may run with reduced timing, but they still register as running animations in the browser.

Reproduction

<MotionConfig skipAnimations={true}>
  <MyComponent />
</MotionConfig>

function MyComponent() {
  const [scope, animate] = useAnimate();

  useEffect(() => {
    // This animation still fires via WAAPI despite skipAnimations={true}
    animate(scope.current, { opacity: [0, 1] }, { duration: 0.3 });
  }, []);

  return <div ref={scope}>Hello</div>;
}

Impact

This is particularly problematic for e2e testing with Playwright. Playwright's toHaveScreenshot() calls element.getAnimations() to check element stability before capturing. WebKit reports the zero-duration WAAPI animations created by useAnimate as still running, causing the stability check to time out indefinitely — even though skipAnimations is true and the app has explicitly opted out of all animations.

Declarative animations don't have this problem because they go through VisualElement, which checks this.skipAnimationsConfig.

Expected behaviour

useAnimate's returned animate function should be a no-op (or resolve immediately without creating WAAPI animations) when MotionConfig.skipAnimations is true, consistent with how declarative animations behave.

Workaround

We created a useSafeAnimate wrapper that reads skipAnimations from MotionConfigContext and returns a no-op animate function when it's true:

export function useSafeAnimate<T extends Element = HTMLDivElement>() {
  const [scope, animate] = useAnimate<T>();
  const { skipAnimations } = useContext(MotionConfigContext);
  const shouldReduceMotion = useReducedMotionConfig();
  const shouldSkip = !!skipAnimations || !!shouldReduceMotion;

  // return no-op animate when shouldSkip is true
  // ...
}

Relevant source

In use-animate.ts, the hook calls useReducedMotionConfig() but does not read skipAnimations from MotionConfigContext.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions