// Dependencies: react ^18, lucide-react
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
export type ToastVariant = 'success' | 'error' | 'info';
export type Toast = {
id: string;
variant: ToastVariant;
message: string;
action?: { label: string; onClick: () => void };
/** ms before auto-dismiss. 0 = never auto-dismiss. */
duration: number;
};
export type ToastInput = Omit<Toast, 'id' | 'duration'> & { duration?: number };
const DEFAULT_DURATION = 4000;
export function useToasts() {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = useCallback((input: ToastInput) => {
const id = `t-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
setToasts((prev) => [
...prev,
{ id, duration: input.duration ?? DEFAULT_DURATION, ...input },
]);
}, []);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const clear = useCallback(() => setToasts([]), []);
return { toasts, toast, dismiss, clear };
}
type StackProps = {
toasts: Toast[];
onDismiss: (id: string) => void;
};
export function ToastStack({ toasts, onDismiss }: StackProps) {
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-[360px] max-w-[calc(100vw-2rem)] pointer-events-none">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={() => onDismiss(t.id)} />
))}
</div>
);
}
const VARIANTS: Record<
ToastVariant,
{ Icon: typeof CheckCircle2; iconClass: string; progressClass: string }
> = {
success: { Icon: CheckCircle2, iconClass: 'text-emerald-400', progressClass: 'bg-emerald-500' },
error: { Icon: AlertCircle, iconClass: 'text-red-400', progressClass: 'bg-red-500' },
info: { Icon: Info, iconClass: 'text-indigo-400', progressClass: 'bg-indigo-500' },
};
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const [mounted, setMounted] = useState(false);
const [leaving, setLeaving] = useState(false);
const [progress, setProgress] = useState(100);
const startedAt = useRef(Date.now());
const close = useCallback(() => {
setLeaving(true);
setTimeout(onDismiss, 180);
}, [onDismiss]);
useEffect(() => {
const id = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(id);
}, []);
useEffect(() => {
if (toast.duration === 0) return;
const tick = setInterval(() => {
const elapsed = Date.now() - startedAt.current;
const remaining = Math.max(0, 100 - (elapsed / toast.duration) * 100);
setProgress(remaining);
if (remaining === 0) {
clearInterval(tick);
close();
}
}, 50);
return () => clearInterval(tick);
}, [toast.duration, close]);
const { Icon, iconClass, progressClass } = VARIANTS[toast.variant];
const visible = mounted && !leaving;
return (
<div
role="status"
aria-live="polite"
className={cn(
'pointer-events-auto bg-zinc-900 border border-white/10 rounded-lg shadow-2xl overflow-hidden transition-all duration-200 ease-out',
visible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4'
)}
>
<div className="flex items-start gap-3 p-3.5">
<Icon className={cn('w-4 h-4 shrink-0 mt-0.5', iconClass)} />
<div className="flex-1 min-w-0">
<p className="text-sm text-white leading-snug">{toast.message}</p>
{toast.action && (
<button
onClick={toast.action.onClick}
className={cn('text-xs font-semibold mt-1.5 hover:opacity-80 transition-opacity', iconClass)}
>
{toast.action.label}
</button>
)}
</div>
<button
onClick={close}
aria-label="Dismiss"
className="text-zinc-500 hover:text-zinc-200 transition-colors shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{toast.duration > 0 && (
<div
className={cn('h-0.5 transition-[width] ease-linear', progressClass)}
style={{ width: `${progress}%`, transitionDuration: '50ms' }}
/>
)}
</div>
);
}