Framer Motion useAnimate Returns Empty Animations Array. Fix for motion v12 and prefers-reduced-motion

The goal
Spring pop animation on a button when it goes from disabled to enabled. Track previous disabled with a ref, detect the flip in useEffect, fire an imperative animation.
Simple enough.
First attempt. useAnimate on the motion element.
const [scope, animate] = useAnimate<HTMLButtonElement>()
useEffect(() => {
if (wasDisabled.current && !disabled) {
animate(scope.current, { scale: 0.92, y: 4 }, { duration: 0 })
animate(scope.current, { scale: 1, y: 0 }, { type: 'spring', stiffness: 500, damping: 10 })
}
wasDisabled.current = disabled
}, [disabled, animate, scope])
return (
<motion.button ref={scope} variants={buttonVariants} ...>
Both animate() calls return GroupAnimationWithThen with animations: []. Empty array. Nothing moves.
Second attempt. Animate a wrapper instead.
Theory was the variant system on motion.button owns y and scale, so useAnimate can't touch them on the same element.
Moved it to a plain wrapper div.
const [wrapperScope, animate] = useAnimate<HTMLDivElement>()
// same animate calls...
return (
<div ref={wrapperScope}>
<motion.button variants={buttonVariants} ...>
Still animations: []. Plain div, no variants. The variant conflict theory was wrong.
The actual root cause.
Traced through the motion v12 source. Here is what happens.
useAnimatereads the OS-levelprefers-reduced-motionviauseReducedMotionConfig().That value flows into
animateTarget(), which checks each property.scale,y,x,rotate, all transforms live in apositionalKeysset.When
prefers-reduced-motionis active, every positional key gets{ type: false }.{ type: false }setsshouldSkip = trueinanimateMotionValue.Skipped animations return
undefined, get filtered out.You get
new GroupAnimationWithThen([]). Empty. No animation.
Source locations in motion-dom.
animation/interfaces/visual-element-target.mjs— reduced motion check per key.animation/interfaces/motion-value.mjs— the skip logic.render/utils/keys-transform.mjs— thepositionalKeysset.
Silent. No warning, no error. Just nothing.
macOS: System Settings > Accessibility > Display > Reduce motion. If on, every useAnimate call targeting transforms produces zero animations.
The fix. Web Animations API.
useAnimate is a motion abstraction. element.animate() does not go through motion's reduced-motion pipeline.
import { useEffect, useRef } from 'react'
const BOUNCE_EASING = 'cubic-bezier(0.22, 1.2, 0.36, 1)'
function Button({ disabled, children }) {
const wrapperRef = useRef<HTMLDivElement>(null)
const wasDisabled = useRef(disabled)
useEffect(() => {
if (wasDisabled.current && !disabled && wrapperRef.current) {
wrapperRef.current.animate(
[
{ transform: 'scale(0.97) translateY(2px)' },
{ transform: 'scale(1.03) translateY(-3px)' },
{ transform: 'scale(0.995) translateY(0.5px)' },
{ transform: 'scale(1) translateY(0)' },
],
{ duration: 450, easing: BOUNCE_EASING }
)
}
wasDisabled.current = disabled
}, [disabled])
return (
<div ref={wrapperRef} style={{ display: 'inline-flex' }}>
<button disabled={disabled}>{children}</button>
</div>
)
}
Slight compress, overshoot up, tiny settle, rest. The cubic-bezier control point above 1.0 (1.2) creates the spring overshoot.
Animate the wrapper, not the motion.* element. Plain ref. .animate() directly.
When to use which.
| Approach | Reduced motion aware | Works with variants | Imperative |
|---|---|---|---|
| motion variants | Yes, skips silently | Yes | No |
useAnimate |
Yes, skips silently | Conflicts on same element | Yes |
| Web Animations API | No, always runs | No conflict | Yes |
For imperative one-off effects like state transition pops that need to always fire, the Web Animations API is the right tool. Native, zero-dependency, does not care what motion thinks about your preferences.





