Toast

Transient notifications with tones, auto-dismiss with pause-on-hover, and actions.

import { Toast, useToasts, ToastData }from "@/components/ui"

Tones

Full source

The complete component, read from src/components/ui/toast.tsx.

toast.tsx
"use client";

import { useState, useEffect, useCallback } from "react";
import { CircleCheck, AlertTriangle, Info, X } from "lucide-react";
import { cn } from "@/lib/utils";

type ToastTone = "success" | "error" | "warning" | "info";

export interface ToastData {
  id: number;
  tone: ToastTone;
  title: string;
  body?: string;
  duration: number;
  sticky?: boolean;
  action?: { label: string; run?: () => void };
}

const TOAST_TONE: Record<
  ToastTone,
  { icon: typeof CircleCheck; text: string; bar: string }
> = {
  success: { icon: CircleCheck, text: "text-accent", bar: "var(--color-accent)" },
  error: { icon: AlertTriangle, text: "text-error", bar: "var(--color-error)" },
  warning: { icon: AlertTriangle, text: "text-warning", bar: "var(--color-warning)" },
  info: { icon: Info, text: "text-text-secondary", bar: "var(--color-text-secondary)" },
};

interface ToastProps {
  t: ToastData;
  onClose: () => void;
}

export function Toast({ t, onClose }: ToastProps) {
  const [paused, setPaused] = useState(false);
  const [left, setLeft] = useState(100);
  const sticky = t.tone === "error" || t.sticky;

  useEffect(() => {
    if (sticky || paused) return;
    const id = setInterval(() => {
      setLeft((v) => {
        const next = v - 100 / (t.duration / 50);
        if (next <= 0) { clearInterval(id); return 0; }
        return next;
      });
    }, 50);
    return () => clearInterval(id);
  }, [paused, sticky, t.duration]);

  useEffect(() => {
    if (left <= 0 && !sticky) onClose();
  }, [left, sticky, onClose]);

  const cfg = TOAST_TONE[t.tone];
  const Icon = cfg.icon;

  return (
    <div
      role={t.tone === "error" ? "alert" : "status"}
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={() => setPaused(false)}
      className="pointer-events-auto relative w-full overflow-hidden rounded-lg border border-border bg-surface shadow-lg sm:w-80"
    >
      <div className="flex gap-2.5 p-3">
        <Icon size={16} className={cn("mt-0.5 shrink-0", cfg.text)} aria-hidden />
        <div className="min-w-0 flex-1">
          <p className="text-sm font-medium text-text">{t.title}</p>
          {t.body && <p className="mt-0.5 text-xs leading-snug text-text-secondary">{t.body}</p>}
          {t.action && (
            <button
              onClick={() => { t.action!.run?.(); onClose(); }}
              className={cn(
                "mt-1.5 text-xs font-medium underline underline-offset-2",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded",
                cfg.text
              )}
            >
              {t.action.label}
            </button>
          )}
        </div>
        <button
          onClick={onClose}
          aria-label="Dismiss"
          className="shrink-0 rounded p-0.5 text-text-tertiary hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
        >
          <X size={14} aria-hidden />
        </button>
      </div>
      {!sticky && (
        <span
          className="absolute bottom-0 left-0 h-0.5 transition-all"
          style={{ width: `${left}%`, background: cfg.bar, opacity: 0.5 }}
        />
      )}
    </div>
  );
}

export function useToasts() {
  const [list, setList] = useState<ToastData[]>([]);
  const push = useCallback((t: Partial<ToastData> & { title: string }) => {
    setList((l) => [...l, { id: Date.now() + Math.random(), duration: 5000, tone: "info", ...t } as ToastData].slice(-4));
  }, []);
  const close = useCallback((id: number) => setList((l) => l.filter((x) => x.id !== id)), []);
  return { list, push, close };
}