UI Library

Toast stack

The four notification tones — success, error, warning, info — stacked as they would appear after several events. Position-agnostic by default so it previews inline; pass floating to pin it to the viewport corner.

Feedbacktoastnotificationstatus
Preview
Changes saved
Your workspace is up to date.
New version available
Reload to pick up v2.4.
apps/site/blocks/components/ToastStack.tsx
'use client';

import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react';

export type ToastTone = 'success' | 'error' | 'warning' | 'info';

export interface ToastItem {
  id: string;
  tone: ToastTone;
  title: string;
  body?: string;
}

// Full literal class strings per tone. An interpolated `text-${tone}-500` would
// be invisible to Tailwind's scanner and emit no utility at all.
const TONE = {
  success: { Icon: CheckCircle2, icon: 'text-emerald-500', ring: 'border-emerald-500/30' },
  error: { Icon: XCircle, icon: 'text-destructive', ring: 'border-destructive/30' },
  warning: { Icon: AlertTriangle, icon: 'text-amber-500', ring: 'border-amber-500/30' },
  info: { Icon: Info, icon: 'text-primary', ring: 'border-primary/30' },
} as const;

const DEFAULT_TOASTS: ToastItem[] = [
  { id: '1', tone: 'success', title: 'Changes saved', body: 'Your workspace is up to date.' },
  { id: '2', tone: 'error', title: 'Upload failed', body: 'report.pdf exceeds the 25 MB limit.' },
  { id: '3', tone: 'warning', title: 'Connection unstable', body: 'Retrying in the background.' },
  { id: '4', tone: 'info', title: 'New version available', body: 'Reload to pick up v2.4.' },
];

export function ToastStack({
  toasts = DEFAULT_TOASTS,
  /**
   * Pin to the bottom-right of the viewport. Off by default so the component
   * stays position-agnostic — in most apps the toast host owns placement, not
   * the toast itself.
   */
  floating = false,
  onDismiss,
}: {
  toasts?: ToastItem[];
  floating?: boolean;
  onDismiss?: (id: string) => void;
}) {
  return (
    <div
      role="region"
      aria-label="Notifications"
      className={
        floating
          ? 'pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2'
          : 'flex w-full max-w-sm flex-col gap-2'
      }
    >
      {toasts.map((toast) => (
        <Toast key={toast.id} toast={toast} onDismiss={onDismiss} />
      ))}
    </div>
  );
}

export function Toast({
  toast,
  onDismiss,
}: {
  toast: ToastItem;
  onDismiss?: (id: string) => void;
}) {
  const { Icon, icon, ring } = TONE[toast.tone];
  return (
    <div
      // `role=alert` interrupts a screen reader; only the failure cases earn it.
      role={toast.tone === 'error' || toast.tone === 'warning' ? 'alert' : 'status'}
      className={`pointer-events-auto flex items-start gap-3 rounded-lg border ${ring} bg-card p-3 shadow-lg`}
    >
      <Icon className={`mt-0.5 h-4 w-4 shrink-0 ${icon}`} aria-hidden />
      <div className="min-w-0 flex-1">
        <div className="text-sm font-medium leading-tight">{toast.title}</div>
        {toast.body ? (
          <div className="mt-0.5 text-sm text-muted-foreground">{toast.body}</div>
        ) : null}
      </div>
      <button
        type="button"
        onClick={() => onDismiss?.(toast.id)}
        aria-label={`Dismiss: ${toast.title}`}
        className="-m-1 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
      >
        <X className="h-3.5 w-3.5" />
      </button>
    </div>
  );
}

Dependencies

  • lucide-react — npm package

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.