Notification Toast
Stacking toasts with swipe-to-dismiss, auto-dismiss progress bar, and spring entrance animations
overlaytoastnotificationalertfeedbackanimation
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { motion, AnimatePresence, useMotionValue, useTransform } from "framer-motion";
type ToastType = "success" | "error" | "warning" | "info";
interface Toast {
id: string;
title: string;
description?: string;
type: ToastType;
duration?: number;
}
interface NotificationToastProps {
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
className?: string;
}
const ICONS: Record<ToastType, React.ReactNode> = {
success: (
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
),
error: (
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
</svg>
),
warning: (
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
),
info: (
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
),
};
const TYPE_COLORS: Record<ToastType, { icon: string; bar: string }> = {
success: { icon: "text-emerald-400", bar: "bg-emerald-400" },
error: { icon: "text-red-400", bar: "bg-red-400" },
warning: { icon: "text-amber-400", bar: "bg-amber-400" },
info: { icon: "text-sky-400", bar: "bg-sky-400" },
};
// Global toast state
let addToastGlobal: ((toast: Omit<Toast, "id">) => void) | null = null;
export function toast(props: Omit<Toast, "id">) {
addToastGlobal?.(props);
}
function ToastItem({
toast: t,
onDismiss,
}: {
toast: Toast;
onDismiss: (id: string) => void;
}) {
const x = useMotionValue(0);
const opacity = useTransform(x, [-150, 0, 150], [0, 1, 0]);
const colors = TYPE_COLORS[t.type];
const duration = t.duration ?? 4000;
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (duration > 0) {
timerRef.current = setTimeout(() => onDismiss(t.id), duration);
}
return () => clearTimeout(timerRef.current);
}, [t.id, duration, onDismiss]);
return (
<motion.div
layout
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.15 } }}
style={{ x, opacity }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > 100) {
onDismiss(t.id);
}
}}
className="relative w-80 cursor-grab overflow-hidden rounded-xl border border-white/10 bg-gray-900 shadow-xl active:cursor-grabbing"
>
<div className="flex items-start gap-3 p-4">
<span className={`mt-0.5 shrink-0 ${colors.icon}`}>
{ICONS[t.type]}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{t.title}</p>
{t.description && (
<p className="mt-0.5 text-xs text-gray-400">{t.description}</p>
)}
</div>
<button
onClick={() => onDismiss(t.id)}
className="shrink-0 rounded-md p-0.5 text-gray-500 transition-colors hover:text-gray-300"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Auto-dismiss progress bar */}
{duration > 0 && (
<motion.div
initial={{ scaleX: 1 }}
animate={{ scaleX: 0 }}
transition={{ duration: duration / 1000, ease: "linear" }}
className={`h-0.5 origin-left ${colors.bar}`}
/>
)}
</motion.div>
);
}
export default function NotificationToast({
position = "top-right",
className = "",
}: NotificationToastProps) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((props: Omit<Toast, "id">) => {
const id = Math.random().toString(36).slice(2, 9);
setToasts((prev) => [{ ...props, id }, ...prev].slice(0, 5));
}, []);
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
useEffect(() => {
addToastGlobal = addToast;
return () => {
addToastGlobal = null;
};
}, [addToast]);
const positionClasses = {
"top-right": "top-4 right-4",
"top-left": "top-4 left-4",
"bottom-right": "bottom-4 right-4",
"bottom-left": "bottom-4 left-4",
};
return (
<div
className={`fixed z-50 flex flex-col gap-2 ${positionClasses[position]} ${className}`}
>
<AnimatePresence mode="popLayout">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismissToast} />
))}
</AnimatePresence>
</div>
);
}