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?
- Instant animations everywhere
- No need to stub out AnimatePresence
- Full framer-motion API stays available
- No tedious timer advancement for framer-motion internals
- Optional override to test nonzero durations
- Since AnimatePresence still works, your enter/exit lifecycles fire as expected, so you can use
await waitFor(...)
when needed - ⚠️ NOTE: see "Shortcomings of theonLayoutAnimationComplete
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,
}
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()
})
})
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()
})
Scalability notes
-
Proxy catches new motion exports - if framer-motion adds
motion.svg
ormotion.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 needawait 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.