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.
Description
The imperative
useAnimatehook does not respectMotionConfig'sskipAnimationsprop. It only checksreducedMotionviauseReducedMotionConfig(), while declarative animations (initial/animate/exit) respect bothskipAnimationsandreducedMotionthrough theVisualElementclass.This means that even with
<MotionConfig skipAnimations={true}>,useAnimatestill creates WAAPI animations — they may run with reduced timing, but they still register as running animations in the browser.Reproduction
Impact
This is particularly problematic for e2e testing with Playwright. Playwright's
toHaveScreenshot()callselement.getAnimations()to check element stability before capturing. WebKit reports the zero-duration WAAPI animations created byuseAnimateas still running, causing the stability check to time out indefinitely — even thoughskipAnimationsis true and the app has explicitly opted out of all animations.Declarative animations don't have this problem because they go through
VisualElement, which checksthis.skipAnimationsConfig.Expected behaviour
useAnimate's returnedanimatefunction should be a no-op (or resolve immediately without creating WAAPI animations) whenMotionConfig.skipAnimationsistrue, consistent with how declarative animations behave.Workaround
We created a
useSafeAnimatewrapper that readsskipAnimationsfromMotionConfigContextand returns a no-op animate function when it's true:Relevant source
In
use-animate.ts, the hook callsuseReducedMotionConfig()but does not readskipAnimationsfromMotionConfigContext.