UX Standards — 29: Toast and Alert Standards
Governs: All user feedback messages — transient toasts, persistent alerts, inline banners, and error display.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
The Four Feedback Mechanisms
| Mechanism | Persistence | Blocking | When To Use |
|---|---|---|---|
toast() | Transient (auto-dismiss) | No | Action confirmation, background job status |
Alert (shadcn) | Persistent until dismissed or fixed | No | Page-level warnings affecting workflow |
InfoBanner | Persistent, dismissible | No | Announcements, feature notices |
Inline field error (FormMessage) | Until field is corrected | No | Form validation errors |
Golden Rule: Field validation errors NEVER go in a toast. Toast is never for form errors.
1. Toast (useToast)
const { toast } = useToast();
// Success
toast({
title: t('toast.saved'),
description: t('toast.bookingCreated'),
duration: 4000,
});
// Error (recoverable)
toast({
title: t('toast.error'),
description: errorMessage,
variant: 'destructive',
duration: 8000,
});
// With action (e.g., undo)
toast({
title: t('toast.deleted'),
description: t('toast.bookingDeleted'),
action: (
<ToastAction altText={t('common.undo')} onClick={handleUndo}>
{t('common.undo')}
</ToastAction>
),
duration: 6000,
});
// Long-running job started
toast({
title: t('toast.exportStarted'),
description: t('toast.exportDescription'),
duration: 4000,
});
Duration Standards
| Scenario | Duration |
|---|---|
| Success confirmation | 4000 ms |
| Info (job started, etc.) | 4000 ms |
| Warning | 6000 ms |
| Error (recoverable) | 8000 ms |
| Critical error | Infinity (manual dismiss) — use Alert instead |
Toast Position
Bottom-right. Configured once in root layout via <Toaster />. Never change position per-toast.
When To Use Toast
✅ Record saved / created / deleted successfully
✅ Export/import started in background
✅ Copy to clipboard successful
✅ Link/invite sent
✅ Session about to expire warning
❌ Form validation errors (use FormMessage)
❌ Critical system errors (use Alert)
❌ Information the user needs to read carefully (use Alert or InfoBanner)
2. Alert Component (Persistent Page-Level)
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
// Warning — configuration issue
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('alert.configurationIncomplete')}</AlertTitle>
<AlertDescription>
{t('alert.configurationDescription')}
<Button variant="link" className="p-0 h-auto" asChild>
<Link href="/settings">{t('alert.goToSettings')}</Link>
</Button>
</AlertDescription>
</Alert>
// Destructive — blocking error
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t('alert.syncFailed')}</AlertTitle>
<AlertDescription>{errorDetail}</AlertDescription>
</Alert>
When To Use Alert
✅ API connection failing — user needs to fix before proceeding ✅ Required configuration missing ✅ Form submission failed (server error, non-field) ✅ Batch import has warnings the user should review ✅ Record is in a locked/cancelled state that affects editing ❌ Transient confirmations (use toast) ❌ General announcements (use InfoBanner)
Placement: Immediately above the affected content (above the form, above the table). Never float.
3. InfoBanner (Shared Component)
// packages/shared/components/shared/info-banner.tsx
interface InfoBannerProps {
id: string; // unique ID for localStorage dismiss persistence
title: string;
description?: string;
variant?: 'info' | 'warning' | 'success';
action?: { label: string; href: string };
}
// Usage
<InfoBanner
id="new-export-feature-2026-03"
title={t('banner.newExportTitle')}
description={t('banner.newExportDescription')}
variant="info"
action={{ label: t('banner.learnMore'), href: '/docs/export' }}
/>
InfoBanner renders at the TOP of page content (below page header, above stat cards). It includes an X dismiss button. Dismissal stored in localStorage so it doesn't reappear after the user closes it.
Variants
| Variant | Icon | Left border |
|---|---|---|
info | Info | border-l-4 border-info |
warning | AlertTriangle | border-l-4 border-warning |
success | CheckCircle2 | border-l-4 border-success |
4. Inline Field Error
Always via FormMessage in React Hook Form. Never customized.
<FormField render={({ field }) => (
<FormItem>
<FormLabel>{t('field.name')}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* Renders automatically from Zod resolver */}
</FormItem>
)} />
Style: text-xs text-destructive (built into FormMessage). Appears below input. No icon needed — color communicates error state.
Error Hierarchy (Decision Tree)
An error occurred — which mechanism?
Is it a FORM FIELD error (validation)?
→ FormMessage (inline below field)
Is it a SERVER/SUBMISSION error (non-field)?
→ Alert variant="destructive" above form actions
Is it a BACKGROUND JOB failure (async)?
→ toast variant='destructive' duration=8000
→ AND NotificationToastMonitor fires critical notification toast
Is it a PAGE-LEVEL configuration problem?
→ Alert at top of content area
Is it a FEATURE ANNOUNCEMENT?
→ InfoBanner (dismissible)
Is it ACTION CONFIRMATION (save, delete, copy)?
→ toast (success or destructive, 4-8s)
Violation Checklist
- Form validation errors use
FormMessage— NEVER toast - Toast duration: success=4000, warning=6000, error=8000
- Critical errors that require user action use
Alert, not toast -
Alertvariant="destructive" always hasAlertCircleicon - InfoBanner has a persistent dismiss (localStorage keyed by
id) - Toast position is bottom-right (never overridden)
-
<Toaster />mounted once in root layout - Server submission errors use
Alertabove form actions - Toast action button present when "Undo" is possible
- All toast titles/descriptions go through
t()— no hardcoded English