Toast / Snackbar Stack

A bottom-right toast stack with success / error / info variants, optional action links, manual dismiss, and a per-toast progress bar that auto-dismisses on timeout. Smooth slide-in transitions; pass duration: 0 to keep a toast persistent. Inspired by Vercel.

NotificationsInspired by Vercel
Live PreviewInteractive
Vercel · Build pipeline

Trigger a toast

Hover a toast and click × to dismiss · Triggered toasts auto-dismiss in 4 s with a progress bar

ToastStack.tsx
// 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>
  );
}