Components
Animated Toast
Interactive toast notifications with smooth animations, multiple types, and various display modes.
Last updated on
Basic Toast
"use client";
import {
AnimatedToastProvider,
useAnimatedToast,
} from "@/components/ui/animated-toast";
function ToastDemoContent() {
const { addToast } = useAnimatedToast();
const showToast = (
type: "success" | "error" | "warning" | "info" | "default",
) => {
addToast({
title: `${type.charAt(0).toUpperCase() + type.slice(1)} Toast`,
message: `This is a ${type} notification message.`,
type,
duration: 4000,
});
};
const showToastWithAction = () => {
addToast({
title: "Action Required",
message: "This toast has an action button.",
type: "info",
action: {
label: "Click me",
onClick: () => alert("Action clicked!"),
},
});
};
return (
<div className="flex flex-wrap gap-4">
<button
onClick={() => showToast("success")}
className="rounded-md bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600"
>
Success Toast
</button>
<button
onClick={() => showToast("error")}
className="rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
>
Error Toast
</button>
<button
onClick={() => showToast("warning")}
className="rounded-md bg-yellow-500 px-4 py-2 text-white transition-colors hover:bg-yellow-600"
>
Warning Toast
</button>
<button
onClick={() => showToast("info")}
className="rounded-md bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600"
>
Info Toast
</button>
<button
onClick={() => showToast("default")}
className="rounded-md bg-gray-500 px-4 py-2 text-white transition-colors hover:bg-gray-600"
>
Default Toast
</button>
<button
onClick={showToastWithAction}
className="rounded-md bg-purple-500 px-4 py-2 text-white transition-colors hover:bg-purple-600"
>
Toast with Action
</button>
</div>
);
}
export function AnimatedToastDemo() {
return (
<AnimatedToastProvider position="top-right" maxToasts={3}>
<div className="p-8">
<ToastDemoContent />
</div>
</AnimatedToastProvider>
);
}Minimal Toast
"use client";
import { useState } from "react";
import { MinimalToast } from "@/components/ui/animated-toast";
export function AnimatedToastMinimalDemo() {
const [showToast, setShowToast] = useState(false);
const triggerToast = (
_type: "success" | "error" | "warning" | "info" | "default",
) => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
return (
<div className="p-8">
<div className="flex flex-wrap gap-4">
<button
onClick={() => triggerToast("success")}
className="rounded-md bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600"
>
Success
</button>
<button
onClick={() => triggerToast("error")}
className="rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
>
Error
</button>
<button
onClick={() => triggerToast("warning")}
className="rounded-md bg-yellow-500 px-4 py-2 text-white transition-colors hover:bg-yellow-600"
>
Warning
</button>
<button
onClick={() => triggerToast("info")}
className="rounded-md bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600"
>
Info
</button>
<button
onClick={() => triggerToast("default")}
className="rounded-md bg-gray-500 px-4 py-2 text-white transition-colors hover:bg-gray-600"
>
Default
</button>
</div>
<MinimalToast
open={showToast}
onClose={() => setShowToast(false)}
message="This is a minimal toast notification!"
type="success"
/>
</div>
);
}Undo Toast
Open in
Item deleted
"use client";
import { useState } from "react";
import { UndoToast } from "@/components/ui/animated-toast";
export function AnimatedToastUndoDemo() {
const [showToast, setShowToast] = useState(false);
const [message, setMessage] = useState("Item deleted");
const deleteItem = () => {
setMessage("Item deleted");
setShowToast(true);
};
const undoDelete = () => {
setMessage("Deletion undone!");
setTimeout(() => setMessage("Item deleted"), 2000);
};
return (
<div className="p-8">
<p className="mb-4 text-muted-foreground">{message}</p>
<button
onClick={deleteItem}
className="rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
>
Delete Item
</button>
<UndoToast
open={showToast}
onClose={() => setShowToast(false)}
onUndo={undoDelete}
message="Item deleted successfully"
duration={5000}
/>
</div>
);
}Notification Toast
"use client";
import { useState } from "react";
import { NotificationToast } from "@/components/ui/animated-toast";
export function AnimatedToastNotificationDemo() {
const [showToast, setShowToast] = useState(false);
const showNotification = () => {
setShowToast(true);
};
return (
<div className="p-8">
<button
onClick={showNotification}
className="rounded-md bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600"
>
Show Notification
</button>
<NotificationToast
open={showToast}
onClose={() => setShowToast(false)}
title="New Message"
message="You have received a new message from John Doe. Check it out!"
avatar="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=40&h=40&fit=crop&crop=face"
time="2 min ago"
/>
</div>
);
}Stacked Notifications
"use client";
import { useState } from "react";
import {
StackedNotifications,
type StackedToast,
} from "@/components/ui/animated-toast";
export function AnimatedToastStackedDemo() {
const [toasts, setToasts] = useState<StackedToast[]>([]);
const addToast = (
type: "success" | "error" | "warning" | "info" | "default",
) => {
const newToast: StackedToast = {
id: Math.random().toString(36).substr(2, 9),
title: `${type.charAt(0).toUpperCase() + type.slice(1)} Notification`,
message: `This is a ${type} notification that will stack with others.`,
type,
};
setToasts((prev) => [newToast, ...prev]);
};
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
};
return (
<div className="p-8">
<div className="mb-8 flex flex-wrap gap-4">
<button
onClick={() => addToast("success")}
className="rounded-md bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600"
>
Add Success
</button>
<button
onClick={() => addToast("error")}
className="rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
>
Add Error
</button>
<button
onClick={() => addToast("warning")}
className="rounded-md bg-yellow-500 px-4 py-2 text-white transition-colors hover:bg-yellow-600"
>
Add Warning
</button>
<button
onClick={() => addToast("info")}
className="rounded-md bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600"
>
Add Info
</button>
</div>
<p className="text-muted-foreground text-sm">
Click buttons to add stacked notifications. They will appear in the
top-right corner.
</p>
<StackedNotifications
toasts={toasts}
onRemove={removeToast}
maxVisible={3}
/>
</div>
);
}Promise Toast
"use client";
import { useState } from "react";
import {
AnimatedToastProvider,
usePromiseToast,
} from "@/components/ui/animated-toast";
function PromiseToastDemoContent() {
const promiseToast = usePromiseToast();
const [result, setResult] = useState<string>("");
const simulateAsyncOperation = (shouldSucceed: boolean): Promise<string> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldSucceed) {
resolve("Operation completed successfully!");
} else {
reject(new Error("Operation failed!"));
}
}, 2000);
});
};
const handleSuccess = async () => {
try {
const data = await promiseToast({
promise: simulateAsyncOperation(true),
loading: "Processing your request...",
success: (result) => `Success: ${result}`,
error: (err) => `Error: ${err.message}`,
});
setResult(data);
} catch (_error) {
// Error already handled by toast
}
};
const handleError = async () => {
try {
const data = await promiseToast({
promise: simulateAsyncOperation(false),
loading: "Processing your request...",
success: (result) => `Success: ${result}`,
error: (err) => `Error: ${err.message}`,
});
setResult(data);
} catch (_error) {
// Error already handled by toast
}
};
return (
<div className="p-8">
<p className="mb-4 text-muted-foreground">
Click buttons to simulate async operations with toast feedback.
</p>
<div className="mb-4 flex flex-wrap gap-4">
<button
onClick={handleSuccess}
className="rounded-md bg-green-500 px-4 py-2 text-white transition-colors hover:bg-green-600"
>
Simulate Success
</button>
<button
onClick={handleError}
className="rounded-md bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600"
>
Simulate Error
</button>
</div>
{result && (
<div className="rounded-md bg-muted p-4">
<p className="font-medium text-sm">Result:</p>
<p className="text-muted-foreground text-sm">{result}</p>
</div>
)}
</div>
);
}
export function AnimatedToastPromiseDemo() {
return (
<AnimatedToastProvider position="top-center">
<PromiseToastDemoContent />
</AnimatedToastProvider>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/animated-toast"Manual
Install the following dependencies:
npm install motion
lucide-reactCopy and paste the following code into your project. component/ui/animated-toast.tsx
import {
AlertCircle,
AlertTriangle,
Bell,
CheckCircle,
Info,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
// Toast Types
type ToastType = "success" | "error" | "warning" | "info" | "default";
type ToastPosition =
| "top-right"
| "top-left"
| "top-center"
| "bottom-right"
| "bottom-left"
| "bottom-center";
interface Toast {
id: string;
title?: string;
message: string;
type?: ToastType;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
interface ToastContextType {
toasts: Toast[];
addToast: (toast: Omit<Toast, "id">) => string;
removeToast: (id: string) => void;
clearAll: () => void;
}
const ToastContext = React.createContext<ToastContextType | null>(null);
// Toast Provider
interface AnimatedToastProviderProps {
children: React.ReactNode;
position?: ToastPosition;
maxToasts?: number;
}
export function AnimatedToastProvider({
children,
position = "top-right",
maxToasts = 5,
}: AnimatedToastProviderProps) {
const [toasts, setToasts] = React.useState<Toast[]>([]);
const addToast = React.useCallback(
(toast: Omit<Toast, "id">) => {
const id = Math.random().toString(36).substr(2, 9);
setToasts((prev) => {
const newToasts = [...prev, { ...toast, id }];
return newToasts.slice(-maxToasts);
});
return id;
},
[maxToasts],
);
const removeToast = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const clearAll = React.useCallback(() => {
setToasts([]);
}, []);
const positionClasses: Record<ToastPosition, string> = {
"top-right": "top-4 right-4",
"top-left": "top-4 left-4",
"top-center": "top-4 left-1/2 -translate-x-1/2",
"bottom-right": "bottom-4 right-4",
"bottom-left": "bottom-4 left-4",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
};
const isTop = position.startsWith("top");
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast, clearAll }}>
{children}
<div
className={cn(
"pointer-events-none fixed z-50 flex flex-col gap-2",
positionClasses[position],
)}
>
<AnimatePresence mode="popLayout">
{(isTop ? toasts : [...toasts].reverse()).map((toast, index) => (
<ToastItem
key={toast.id}
toast={toast}
index={index}
onRemove={() => removeToast(toast.id)}
isTop={isTop}
/>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}
// Toast Item
interface ToastItemProps {
toast: Toast;
index: number;
onRemove: () => void;
isTop: boolean;
}
function ToastItem({ toast, index, onRemove, isTop }: ToastItemProps) {
const { type = "default", title, message, duration = 5000, action } = toast;
React.useEffect(() => {
if (duration > 0) {
const timer = setTimeout(onRemove, duration);
return () => clearTimeout(timer);
}
}, [duration, onRemove]);
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="h-5 w-5 text-emerald-500" />,
error: <AlertCircle className="h-5 w-5 text-red-500" />,
warning: <AlertTriangle className="h-5 w-5 text-amber-500" />,
info: <Info className="h-5 w-5 text-blue-500" />,
default: <Bell className="h-5 w-5 text-muted-foreground" />,
};
const borderColors: Record<ToastType, string> = {
success: "border-l-emerald-500",
error: "border-l-red-500",
warning: "border-l-amber-500",
info: "border-l-blue-500",
default: "border-l-border",
};
return (
<motion.div
layout
initial={{ opacity: 0, y: isTop ? -20 : 20, scale: 0.9 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: {
type: "spring",
stiffness: 500,
damping: 30,
delay: index * 0.05,
},
}}
exit={{
opacity: 0,
scale: 0.9,
x: 100,
transition: { duration: 0.2 },
}}
className={cn(
"pointer-events-auto min-w-[320px] max-w-[420px] rounded-lg border border-l-4 bg-card p-4 shadow-lg",
borderColors[type],
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">{icons[type]}</div>
<div className="min-w-0 flex-1">
{title && <p className="font-medium text-card-foreground">{title}</p>}
<p className={cn("text-muted-foreground text-sm", title && "mt-1")}>
{message}
</p>
{action && (
<button
onClick={action.onClick}
className="mt-2 font-medium text-primary text-sm hover:underline"
>
{action.label}
</button>
)}
</div>
<button
onClick={onRemove}
className="flex-shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Progress bar */}
{duration > 0 && (
<motion.div
initial={{ scaleX: 1 }}
animate={{ scaleX: 0 }}
transition={{ duration: duration / 1000, ease: "linear" }}
className={cn(
"absolute right-0 bottom-0 left-0 h-1 origin-left rounded-b-lg",
type === "success" && "bg-emerald-500/30",
type === "error" && "bg-red-500/30",
type === "warning" && "bg-amber-500/30",
type === "info" && "bg-blue-500/30",
type === "default" && "bg-muted",
)}
/>
)}
</motion.div>
);
}
// Hook to use toast
export function useAnimatedToast() {
const context = React.useContext(ToastContext);
if (!context) {
throw new Error(
"useAnimatedToast must be used within AnimatedToastProvider",
);
}
return context;
}
// Standalone Toast Components
// Minimal Toast
interface MinimalToastProps {
open: boolean;
onClose: () => void;
message: string;
type?: ToastType;
}
export function MinimalToast({
open,
onClose,
message,
type = "default",
}: MinimalToastProps) {
React.useEffect(() => {
if (open) {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}
}, [open, onClose]);
const bgColors: Record<ToastType, string> = {
success: "bg-emerald-500",
error: "bg-red-500",
warning: "bg-amber-500",
info: "bg-blue-500",
default: "bg-foreground",
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className={cn(
"-translate-x-1/2 fixed bottom-8 left-1/2 z-50 rounded-full px-6 py-3 font-medium text-background text-black text-sm shadow-lg dark:text-white",
bgColors[type],
)}
>
{message}
</motion.div>
)}
</AnimatePresence>
);
}
// Undo Toast
interface UndoToastProps {
open: boolean;
onClose: () => void;
onUndo: () => void;
message: string;
duration?: number;
}
export function UndoToast({
open,
onClose,
onUndo,
message,
duration = 5000,
}: UndoToastProps) {
const [progress, setProgress] = React.useState(100);
React.useEffect(() => {
if (open) {
const interval = setInterval(() => {
setProgress((prev) => {
if (prev <= 0) {
onClose();
return 0;
}
return prev - 100 / (duration / 100);
});
}, 100);
return () => clearInterval(interval);
} else {
setProgress(100);
}
}, [open, duration, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.9 }}
className="-translate-x-1/2 fixed bottom-8 left-1/2 z-50 overflow-hidden rounded-lg bg-foreground text-background shadow-xl"
>
<div className="flex items-center gap-4 px-4 py-3">
<span className="text-sm">{message}</span>
<button
onClick={() => {
onUndo();
onClose();
}}
className="rounded-md bg-primary px-3 py-1 font-semibold text-primary-foreground text-sm transition-opacity hover:opacity-90"
>
Undo
</button>
</div>
<div
className="h-1 bg-primary transition-all duration-100"
style={{ width: `${progress}%` }}
/>
</motion.div>
)}
</AnimatePresence>
);
}
// Notification Toast (with avatar/image)
interface NotificationToastProps {
open: boolean;
onClose: () => void;
title: string;
message: string;
avatar?: string;
time?: string;
}
export function NotificationToast({
open,
onClose,
title,
message,
avatar,
time,
}: NotificationToastProps) {
React.useEffect(() => {
if (open) {
const timer = setTimeout(onClose, 5000);
return () => clearTimeout(timer);
}
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.9 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className="fixed top-4 right-4 z-50 w-80 overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
>
<div className="p-4">
<div className="flex items-start gap-3">
{avatar ? (
// biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx
<img
src={avatar}
alt=""
width={40}
height={40}
className="rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Bell className="h-5 w-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="truncate font-semibold text-card-foreground">
{title}
</p>
{time && (
<span className="text-muted-foreground text-xs">
{time}
</span>
)}
</div>
<p className="mt-0.5 line-clamp-2 text-muted-foreground text-sm">
{message}
</p>
</div>
</div>
</div>
<button
onClick={onClose}
className="absolute top-2 right-2 rounded-full p-1 transition-colors hover:bg-muted"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</motion.div>
)}
</AnimatePresence>
);
}
// Stacked Notifications
export interface StackedToast {
id: string;
title: string;
message: string;
type?: ToastType;
}
interface StackedNotificationsProps {
toasts: StackedToast[];
onRemove: (id: string) => void;
maxVisible?: number;
}
export function StackedNotifications({
toasts,
onRemove,
maxVisible = 3,
}: StackedNotificationsProps) {
const visibleToasts = toasts.slice(0, maxVisible);
const hiddenCount = Math.max(0, toasts.length - maxVisible);
const icons: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="h-5 w-5 text-emerald-500" />,
error: <AlertCircle className="h-5 w-5 text-red-500" />,
warning: <AlertTriangle className="h-5 w-5 text-amber-500" />,
info: <Info className="h-5 w-5 text-blue-500" />,
default: <Bell className="h-5 w-5 text-muted-foreground" />,
};
return (
<div className="fixed top-4 right-4 z-50 w-80">
<AnimatePresence mode="popLayout">
{visibleToasts.map((toast, index) => (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{
opacity: 1 - index * 0.15,
y: index * 8,
scale: 1 - index * 0.05,
zIndex: maxVisible - index,
}}
exit={{ opacity: 0, x: 100 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
style={{
position: index === 0 ? "relative" : "absolute",
top: 0,
left: 0,
right: 0,
}}
className="rounded-lg border border-border bg-card p-4 shadow-lg"
>
<div className="flex items-start gap-3">
{icons[toast.type || "default"]}
<div className="min-w-0 flex-1">
<p className="font-medium text-card-foreground">
{toast.title}
</p>
<p className="mt-0.5 text-muted-foreground text-sm">
{toast.message}
</p>
</div>
<button
onClick={() => onRemove(toast.id)}
className="rounded-md p-1 transition-colors hover:bg-muted"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</motion.div>
))}
</AnimatePresence>
{hiddenCount > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2 text-center text-muted-foreground text-sm"
>
+{hiddenCount} more notifications
</motion.div>
)}
</div>
);
}
// Promise Toast (for async operations)
interface PromiseToastProps<T> {
promise: Promise<T>;
loading: string;
success: string | ((data: T) => string);
error: string | ((err: Error) => string);
}
export function usePromiseToast() {
const { addToast, removeToast } = useAnimatedToast();
return async function promiseToast<T>({
promise,
loading,
success,
error,
}: PromiseToastProps<T>) {
const id = addToast({ message: loading, type: "info", duration: 0 });
try {
const data = await promise;
removeToast(id);
addToast({
message: typeof success === "function" ? success(data) : success,
type: "success",
});
return data;
} catch (err) {
removeToast(id);
addToast({
message: typeof error === "function" ? error(err as Error) : error,
type: "error",
});
throw err;
}
};
}API Reference
AnimatedToastProvider
Prop
Type
Toast Types
Prop
Type
MinimalToast
Prop
Type
UndoToast
Prop
Type
NotificationToast
Prop
Type
StackedNotifications
Prop
Type
PromiseToast
Prop
Type
Notes
- All toast components support different types: success, error, warning, info, and default
- Toast positions can be customized (top-right, top-left, bottom-right, etc.)
- Toasts automatically dismiss after a configurable duration
- The provider manages toast state and stacking
- Promise toasts provide automatic loading/success/error feedback
- Stacked notifications show multiple toasts with a maximum visible limit
- All animations use Framer Motion for smooth transitions
How is this guide?