DEV Community

Pablo Garcia
Pablo Garcia

Posted on • Edited on

Mocking framer-motion v9

Animations can make your UI feel alive, but in tests they often introduce flakiness and slow things down. Here is a strategy that:

  • Makes every motion component render with zero-second transitions
  • Keeps AnimatePresence alive so exit/enter lifecycles fire naturally
  • Lets you override durations on a per-test basis
  • Plays nicely with Jest’s module system and React’s context API

I based my solution loosely on: https://dev.to/tmikeschu/mocking-framer-motion-v4-19go

Why this approach?

  1. Instant animations everywhere
  2. No need to stub out AnimatePresence
  3. Full framer-motion API stays available
  4. No tedious timer advancement for framer-motion internals
  5. Optional override to test nonzero durations
  6. Since AnimatePresence still works, your enter/exit lifecycles fire as expected, so you can use await waitFor(...) when needed - ⚠️ NOTE: see "Shortcomings of the onLayoutAnimationComplete shim" section.

Solution - Jest Mock

Create a Jest setup file (e.g. jest.setup.js) and point your jest.config.js’s setupFilesAfterEnv at it. Inside:

// jest.setup.js
const actual = jest.requireActual('framer-motion')
const { forwardRef, createElement, useContext } = jest.requireActual('react')

// Recursively zero out any duration props in motion configs
function deepReplaceDurations(props, duration) {
  // extract only motion props
  const patch = Object.fromEntries(
    Object.entries(props).filter(([key]) => actual.isValidMotionProp(key))
  )
  const stack = [patch]
  while (stack.length) {
    const cur = stack.pop()
    if (!cur || typeof cur !== 'object') continue
    for (const [key, val] of Object.entries(cur)) {
      if (key === 'duration') {
        cur[key] = duration
      } else if (val && typeof val === 'object') {
        stack.push(val)
      }
    }
  }
  // merge patched motion props back into original props
  return { ...props, ...patch }
}

// Layout animations don’t actually run under Jest, so we “fake”
// them. This helper merges the regular animation callback and
// the layout callback into one, letting you test 
// onLayoutAnimationComplete as well.
const mockOnLayoutAnimationComplete = (props) => (...args) => {
  props.onAnimationComplete?.(...args)
  props.onLayoutAnimationComplete?.(...args)
}

// Proxy componentCache to wrap every motion.<el> on the fly
const componentCache = new Map()
const motion = new Proxy(actual.motion, {
  get(target, key) {
    const Comp = target[key]
    if (!Comp) return Comp

    // Only create one wrapper per component name
    if (!componentCache.has(key)) {
      const Wrapped = forwardRef((props, ref) => {
        // pull duration override from MotionConfigContext
        const cfg = useContext(actual.MotionConfigContext)
        const dur = cfg?.transition?.duration

        // Since Jest won’t run Framer’s layout animations, we
        // manually invoke the callback on every render. If your
        // callback triggers way too early, you will need to
        // add an if-statement to avoid it.
        useEffect(() => {
            patched?.onLayoutAnimationComplete?.();
        });

        // IFF dur is number, zero-out all durations
        const patched = typeof dur === 'number'
          ? deepReplaceDurations(props, dur)
          : props

        return createElement(
            Comp,
            {
                ...patched,
                // wire up both callbacks to unblock your 
                // onLayoutAnimationComplete tests.
                onAnimationComplete:
                    mockOnLayoutAnimationComplete(patched),
                ref,
            },
            props.children
        );
      })
      componentCache.set(key, Wrapped)
    }

    return componentCache.get(key)
  }
})

// re-export everything from framer-motion, but swap out motion
module.exports = {
  ...actual,
  __esModule: true,
  motion,
}
Enter fullscreen mode Exit fullscreen mode

Usage in your tests

Wrap your component under test with MotionConfig in order to apply the zero-duration override:

import { MotionConfig } from 'framer-motion'
import { render, screen, waitFor } from '@testing-library/react'
import MyAnimatedComponent from './MyAnimatedComponent'

test('it fades in text', async () => {
  render(
    <MotionConfig transition={{ duration: 0 }}>
      <MyAnimatedComponent />
    </MotionConfig>
  )

  // animation is instant, but lifecycle is async
  await waitFor(() => {
    expect(screen.getByText('Hello')).toBeVisible()
  })
})
Enter fullscreen mode Exit fullscreen mode

If you omit the transition prop or pass undefined, the mock leaves your props unchanged. That means you can still test nonzero durations in specific cases:

render(
  <MotionConfig transition={{ duration: 1 }}>
    <MyAnimatedComponent />
  </MotionConfig>
)

// this will actually wait about 1s worth of frames under the hood
await waitFor(() => {
  expect(someStub).toHaveBeenCalled()
})
Enter fullscreen mode Exit fullscreen mode

Scalability notes

  • Proxy catches new motion exports - if framer-motion adds motion.svg or motion.video in v9+, the proxy will wrap them automatically without extra maintenance
  • Component cache - we reuse one wrapper per element type, so overhead stays constant no matter how many times you render
  • Deep walker cost - zeroing out durations is O(n) in the size of the props object. In practice props stay small, so you won't notice it in your CI pipeline
  • Async lifecycles - since framer-motion still uses requestAnimationFrame, animations resolve on the next frame tick. You will still need await waitFor(...) or Jest timers if you want to drive them faster

⚠️ Shortcomings of the onLayoutAnimationComplete shim

Framer-Motion’s layout animations (and its onLayoutAnimationComplete) don’t actually run under Jest, so I've introduced a partial shim mockOnLayoutAnimationComplete helper plus a useEffect that fires onLayoutAnimationComplete on every render. While this lets you test your layout-complete logic, it has some caveats:

  • Over-triggering: Fires on each render, leading to duplicate calls.
  • Merged timing: Animation and layout callbacks run back-to-back, not on separate frames.
  • Immediate effects: Handlers execute on mount, ignoring real transition delays.
  • Fragile tests: May need guards (e.g. hasAnimated flag) to prevent early or repeated invocations.

Workarounds

  • Add conditional checks or render-count flags in your callback
  • Simulate frame delays in a custom mock
  • For precise timing, test layout animations in integration/E2E suites

Wrapping up

With this mock in place you get:

  • Instant animations by default
  • Fully functional mounting/unmounting lifecycles (AnimatePresence still works)
  • Ability to enable or disable animations per test
  • Minimal ongoing maintenance as framer-motion evolves

Feel free to tweak the deep walker (for example, zero out delay too), or stub new hooks in v9 if you adopt them. For most motion-based components, this pattern scales nicely and keeps your tests fast and reliable.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.