Mastering AnimatePresence in Framer Motion: The Secret to Smooth Exit Animations
When building modern React applications with Framer Motion, you've probably noticed that while entry animations work beautifully out of the box, exit animations can be... tricky. That's where AnimatePresence comes in—a powerful component that makes your UI transitions buttery smooth.
The Problem: Exit Animations Don't Work by Default
Let's say you have a toast notification component like this:
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, CheckCircle2, AlertCircle, Info } from "lucide-react";
import { cn } from "@/lib/utils";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
icon?: React.ReactNode;
}
const ToastItem = ({
toast,
removeToast,
}: {
toast: Toast;
removeToast: (id: string) => void;
}) => {
useEffect(() => {
const timer = setTimeout(() => {
removeToast(toast.id);
}, 4000);
return () => clearTimeout(timer);
}, [toast.id, removeToast]);
return (
<motion.div
// ❌ No layout prop - elements won't reposition smoothly
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
className={cn(
"pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md border border-white/10 bg-white/80 p-4 backdrop-blur-md dark:bg-zinc-900/80",
"dark:border-zinc-800",
)}
>
<div
className={cn("flex h-8 w-8 items-center justify-center rounded-full", {
"bg-green-500/10 text-green-600 dark:text-green-500":
toast.type === "success",
"bg-red-500/10 text-red-600 dark:text-red-500":
toast.type === "error",
"bg-blue-500/10 text-blue-600 dark:text-blue-500":
toast.type === "info",
})}
>
{toast.icon ? (
toast.icon
) : (
<>
{toast.type === "success" && (
<CheckCircle2 className={cn("h-5 w-5")} />
)}
{toast.type === "error" && (
<AlertCircle className={cn("h-5 w-5")} />
)}
{toast.type === "info" && <Info className={cn("h-5 w-5")} />}
</>
)}
</div>
<div className={cn("flex-1")}>
<h3
className={cn("text-sm font-medium text-zinc-900 dark:text-zinc-100")}
>
{toast.type.charAt(0).toUpperCase() + toast.type.slice(1)}
</h3>
<p className={cn("text-sm text-zinc-500 dark:text-zinc-400")}>
{toast.message}
</p>
</div>
<button
onClick={() => removeToast(toast.id)}
className={cn(
"cursor-pointer text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200",
)}
>
<X className={cn("h-5 w-5")} />
</button>
</motion.div>
);
};
const positionClasses = {
"top-left":
"items-start justify-start sm:items-start sm:justify-start flex-col",
"top-center":
"items-center justify-start sm:items-center sm:justify-start flex-col",
"top-right":
"items-center justify-start sm:items-end sm:justify-start flex-col",
"bottom-left":
"items-start justify-start sm:items-start sm:justify-start flex-col-reverse",
"bottom-center":
"items-center justify-start sm:items-center sm:justify-start flex-col-reverse",
"bottom-right":
"items-center justify-start sm:items-end sm:justify-start flex-col-reverse",
};
export interface ToasterProps {
toasts: Toast[];
removeToast: (id: string) => void;
position?: keyof typeof positionClasses;
}
// ❌ Not wrapped in AnimatePresence - exit animations won't work!
export const Toaster = ({
toasts,
removeToast,
position = "top-right",
}: ToasterProps) => {
return (
<div
className={cn(
"pointer-events-none absolute inset-0 flex gap-2 p-4",
positionClasses[position],
)}
>
{toasts.map((toast) => (
// ❌ No key prop - AnimatePresence won't work properly
<ToastItem toast={toast} removeToast={removeToast} />
))}
</div>
);
};When you remove this component from the DOM, React unmounts it immediately—before the exit animation has a chance to run. The result? An abrupt disappearance instead of a smooth fade-out.
The Solution: AnimatePresence + layout
AnimatePresence solves this by keeping components in the DOM long enough for their exit animations to complete. Here's the corrected version:
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, CheckCircle2, AlertCircle, Info } from "lucide-react";
import { cn } from "@/lib/utils";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
icon?: React.ReactNode;
}
const ToastItem = ({
toast,
removeToast,
}: {
toast: Toast;
removeToast: (id: string) => void;
}) => {
useEffect(() => {
const timer = setTimeout(() => {
removeToast(toast.id);
}, 4000);
return () => clearTimeout(timer);
}, [toast.id, removeToast]);
return (
<motion.div
layout // ✅ Automatically animates position changes when other toasts are removed
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
className={cn(
"pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md border border-white/10 bg-white/80 p-4 backdrop-blur-md dark:bg-zinc-900/80",
"dark:border-zinc-800",
)}
>
<div
className={cn("flex h-8 w-8 items-center justify-center rounded-full", {
"bg-green-500/10 text-green-600 dark:text-green-500":
toast.type === "success",
"bg-red-500/10 text-red-600 dark:text-red-500":
toast.type === "error",
"bg-blue-500/10 text-blue-600 dark:text-blue-500":
toast.type === "info",
})}
>
{toast.icon ? (
toast.icon
) : (
<>
{toast.type === "success" && (
<CheckCircle2 className={cn("h-5 w-5")} />
)}
{toast.type === "error" && (
<AlertCircle className={cn("h-5 w-5")} />
)}
{toast.type === "info" && <Info className={cn("h-5 w-5")} />}
</>
)}
</div>
<div className={cn("flex-1")}>
<h3
className={cn("text-sm font-medium text-zinc-900 dark:text-zinc-100")}
>
{toast.type.charAt(0).toUpperCase() + toast.type.slice(1)}
</h3>
<p className={cn("text-sm text-zinc-500 dark:text-zinc-400")}>
{toast.message}
</p>
</div>
<button
onClick={() => removeToast(toast.id)}
className={cn(
"cursor-pointer text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200",
)}
>
<X className={cn("h-5 w-5")} />
</button>
</motion.div>
);
};
const positionClasses = {
"top-left": "items-start justify-start sm:items-start sm:justify-start flex-col",
"top-center": "items-center justify-start sm:items-center sm:justify-start flex-col",
"top-right": "items-center justify-start sm:items-end sm:justify-start flex-col",
"bottom-left": "items-start justify-start sm:items-start sm:justify-start flex-col-reverse",
"bottom-center": "items-center justify-start sm:items-center sm:justify-start flex-col-reverse",
"bottom-right": "items-center justify-start sm:items-end sm:justify-start flex-col-reverse",
};
export interface ToasterProps {
toasts: Toast[];
removeToast: (id: string) => void;
position?: keyof typeof positionClasses;
}
export const Toaster = ({
toasts,
removeToast,
position = "top-right",
}: ToasterProps) => {
return (
<div
className={cn(
"pointer-events-none absolute inset-0 flex gap-2 p-4",
positionClasses[position],
)}
>
{/* ✅ Wrapped in AnimatePresence - exit animations now work! */}
<AnimatePresence>
{toasts.map((toast) => (
{/* 🔑 Unique key is critical - we'll explain why next */}
<ToastItem key={toast.id} toast={toast} removeToast={removeToast} />
))}
</AnimatePresence>
</div>
);
};Why Keys Are Non-Negotiable
The key prop is essential when using AnimatePresence. Here's why:
1. React Needs to Track Elements
React uses keys to identify which items have changed, been added, or removed. Without a unique key, React can't tell the difference between:
- A component that should exit
- A component that should stay
// ❌ BAD: No keys
<AnimatePresence>
{toasts.map((toast) => (
<ToastItem toast={toast} removeToast={removeToast} />
))}
</AnimatePresence>
// ✅ GOOD: Unique keys (from our actual component)
<AnimatePresence>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} removeToast={removeToast} />
))}
</AnimatePresence>2. AnimatePresence Relies on Keys
AnimatePresence monitors the key prop to detect when children are added or removed. Without keys:
- Exit animations won't trigger
- You'll see console warnings
- Animations may fire on the wrong elements
Conclusion
AnimatePresence is the bridge between React's rendering logic and Framer Motion's animation capabilities. By understanding how it works and always providing proper keys, you can create UI animations that feel native and polished.
Remember: No keys = No exit animations = Sad users 😢
Happy animating! ✨
Resources:
Looking for a developer?
I'm a freelance web developer based in Hyderabad specializing in React & Next.js. If you need help with web application development or building a high-converting landing page, I'd love to chat.