Skip to content
Feedback

Feedback

ToastContainer

Toast stack with a useToast hook, portalled to the body, sliding in and out.

npx bezel-add add toast-container

packages/ui/src/feedback/ToastContainer.tsx · 94 lines

import { useState, useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

interface Toast {
  id: number;
  message: string;
}

let _toastId = 0;
const EXIT_MS = 200;

export function useToast() {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const show = useCallback((message: string, duration = 2500) => {
    const id = ++_toastId;
    setToasts((prev) => [...prev, { id, message }]);
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, duration);
  }, []);

  return { toasts, show };
}

// @starting-style drives the entrance — it's paint-driven, not a mount
// effect + requestAnimationFrame, so it still animates even if the tab was
// backgrounded when the toast was queued (rAF is paused on hidden tabs).
// Exit uses the [data-leaving] attribute, toggled by plain React state.
const toastStyle = `
  .yui-toast {
    transform: translateY(0);
    opacity: 1;
    transition: transform 200ms cubic-bezier(0.23,1,0.32,1), opacity 200ms cubic-bezier(0.23,1,0.32,1);
  }
  @starting-style {
    .yui-toast { transform: translateY(100%); opacity: 0; }
  }
  .yui-toast[data-leaving="true"] { transform: translateY(100%); opacity: 0; }
`;

/**
 * Toasts fire in bursts, so exit uses an interruptible CSS transition (not
 * @keyframes) — the same approach Sonner uses. When a toast drops out of the
 * `toasts` prop it isn't unmounted immediately; it's kept around just long
 * enough to play its exit transition, matching how it slid in.
 */
export function ToastContainer({ toasts }: { toasts: Toast[] }) {
  const [rendered, setRendered] = useState<(Toast & { leaving?: boolean })[]>([]);
  const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());

  useEffect(() => {
    const incomingIds = new Set(toasts.map((t) => t.id));
    setRendered((prev) => {
      const stillLeaving = prev.filter((t) => !incomingIds.has(t.id) && t.leaving);
      const newlyLeaving = prev.filter((t) => !incomingIds.has(t.id) && !t.leaving);

      newlyLeaving.forEach((t) => {
        const timer = setTimeout(() => {
          setRendered((curr) => curr.filter((c) => c.id !== t.id));
          timers.current.delete(t.id);
        }, EXIT_MS);
        timers.current.set(t.id, timer);
      });

      return [...toasts, ...stillLeaving, ...newlyLeaving.map((t) => ({ ...t, leaving: true }))];
    });
  }, [toasts]);

  useEffect(() => {
    const map = timers.current;
    return () => map.forEach(clearTimeout);
  }, []);

  if (rendered.length === 0) return null;

  return createPortal(
    <>
      <style>{toastStyle}</style>
      <div className="fixed bottom-6 right-6 z-[9999] flex flex-col gap-2">
        {rendered.map((t) => (
          <div
            key={t.id}
            data-leaving={t.leaving ? 'true' : undefined}
            className="yui-toast bg-neutral-800 text-white text-sm px-4 py-2.5 rounded-xl shadow-lg"
          >
            {t.message}
          </div>
        ))}
      </div>
    </>,
    document.body
  );
}