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.
Upload failed
report.pdf exceeds the 25 MB limit.
Connection unstable
Retrying in the background.
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.