Animating React Unmounts with AnimatePresence: An Architecture Note
React unmounts components synchronously, so exit animations need a coordinator. An architecture note on AnimatePresence: minimal design, key stability, data boundaries, and failure modes.
20 Apr 2026, 20:56 UTC

React removes a component from the DOM the moment its conditional render flips to false. That is why a naive {open && <Modal/>} can never fade out: the element is gone before any CSS transition can run. Framer Motion's AnimatePresence exists to fix exactly this — it intercepts the unmount, keeps the element in the DOM, plays an exit animation, and only then lets removal happen. This note covers when that machinery is warranted, the smallest design that works, and the operational traps that come with extending a component's lifetime.
Requirements: when you actually need this
Reach for AnimatePresence only when all of the following hold:
- A subtree mounts and unmounts based on state (modals, toasts, route swaps, accordions, list items).
- The exit should be visible to the user, not instant.
- You are already using (or willing to adopt) Framer Motion for that subtree — adding the library solely for one fade is a real bundle cost.
If the exit can be instant, or a simple CSS opacity transition on a persistent element does the job, skip it. Every exit animation you add extends component lifetime, and that has consequences covered below.
The smallest suitable design
The minimal correct setup has three parts: one AnimatePresence wrapper around the conditional subtree, a stable key on each animated direct child, and an exit prop on the motion component. No extra state, no timers, no callbacks.
import { AnimatePresence, motion } from "framer-motion";
function ModalHost({ open, onClose }) {
return (
<AnimatePresence mode="wait">
{open && (
<motion.div
key="modal"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2 }}
>
<Dialog onClose={onClose} />
</motion.div>
)}
</AnimatePresence>
);
}Three details carry the weight here:
- Stable keys.
AnimatePresencetracks children by key. A missing key, a duplicated key, or an array index on a reorderable list will silently break exit animations or animate the wrong element. Use a domain identifier (modal name, toast id), not a render position. mode="wait"for swaps. Themodeprop controls how entering and leaving elements overlap."sync"(the default) runs both at once, which causes layout jumps when a new modal replaces an old one."wait"serializes them: exit finishes, then enter starts. For modals and route swaps,"wait"is the smallest suitable choice."popLayout"exists for cases where the exiting element should be pulled out of layout flow immediately.- Animate transform and opacity only. Animating
heightorwidthduring exit forces layout reflow on every frame.transformandopacityare compositor-friendly and stay smooth under load.
Trust and data boundaries
Animation state is presentational and lives entirely on the client. Treat it as untrusted for anything else:
- Do not gate business logic on
onExitComplete. It is fine for chaining another animation; it is not fine for committing navigation, confirming a deletion, or deciding a fetch is done. If the animation is interrupted, your logic silently never runs. - Do not let the server or a router wait on animation. Close the modal in state immediately; let the visual layer catch up.
- Components held alive by
AnimatePresenceduring exit are still mounted. Subscriptions, timers, and async work inside them must clean up defensively in effect teardown, because React's unmount timing no longer matches the element's disappearance.
Operational checks
Because exit animations defer removal, the failure modes are timing-related. Verify these before shipping:
- DOM lifetime. Set a deliberately slow exit (
duration: 2), toggle the component, and confirm in browser DevTools that the element stays in the DOM until the animation finishes, then disappears. - Rapid toggling. Spam the trigger. Confirm no duplicate or stuck "zombie" elements accumulate — these appear when a key changes mid-exit or an exit is interrupted.
- Reduced motion. Emulate
prefers-reduced-motionin DevTools. The UI must remain fully functional. Framer Motion'suseReducedMotionhook lets you shorten or skip transitions for those users. - Cleanup under exit. If the exiting component holds a listener or interval, confirm it is torn down at React unmount, not when the element visually vanishes.
Failure modes
- Parent unmounts the wrapper. If a component higher in the tree removes the
AnimatePresenceitself (e.g. a route change unmounts the whole section), exit animations inside are skipped entirely. The wrapper must outlive its children. - Zombie elements. A key that changes while an exit is in flight, or an interrupted animation, can leave an element stuck in the DOM. Stable keys and the rapid-toggle check above catch this.
- Leaked work. Because unmount is deferred, a component that assumes "gone from screen = cleaned up" can keep timers or subscriptions running longer than expected.
What would change the design
Revisit this setup if: the list becomes reorderable (switch to layout animations with stable ids and test reordering mid-exit heavily); exits must block navigation (you need an explicit state machine, not animation callbacks); or bundle size becomes a concern (a CSS-only approach on persistent elements may be cheaper). Also note that Framer Motion was reorganized under the motion package in later releases — import paths and prop details are version-sensitive, so confirm prop names against the type definitions in your installed node_modules rather than assuming the snippets above match your major version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.