Skip to main content

Command Palette

Search for a command to run...

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

Published
3 min read
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.

  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.

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.