# 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.

```tsx
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`.

```tsx
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.

1.  `useAnimate` reads the OS-level `prefers-reduced-motion` via `useReducedMotionConfig()`.
    
2.  That value flows into `animateTarget()`, which checks each property.
    
3.  `scale`, `y`, `x`, `rotate`, all transforms live in a `positionalKeys` set.
    
4.  When `prefers-reduced-motion` is active, every positional key gets `{ type: false }`.
    
5.  `{ type: false }` sets `shouldSkip = true` in `animateMotionValue`.
    
6.  Skipped animations return `undefined`, get filtered out.
    
7.  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` — the `positionalKeys` set.
    

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.

```tsx
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.
