UX Standards — 11: Modals and Dialogs
Governs: All modal dialogs, confirmation popups, and form dialogs.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Component: shadcn Dialog — always. Never build custom modals.
Visual Anatomy
┌─────────────────────────────────────────────────────┐
│ [X] │ ← Close button (auto from shadcn)
│ │
│ Dialog Title │ ← DialogHeader
│ Optional description text in muted color │
│ ─────────────────────────────────────────────────── │
│ │
│ [Form fields / content / confirmation text] │ ← DialogBody (flex-1 overflow-y-auto)
│ │
│ ─────────────────────────────────────────────────── │
│ [Cancel] [Primary Action] │ ← DialogFooter
└─────────────────────────────────────────────────────┘
Dialog Sizes
| Size | Class | Max Width | Use For |
|---|---|---|---|
| Small | sm:max-w-md | 448px | Confirmations, simple prompts |
| Medium | sm:max-w-2xl | 672px | Single-form dialogs, detail views |
| Large | sm:max-w-4xl | 896px | Import wizard, complex forms |
| Extra Large | sm:max-w-6xl | 1152px | Multi-panel dialogs, data previews |
| Full-screen (mobile) | w-full h-full rounded-none | Screen | Any modal on mobile |
Never use a fixed w-[500px] or similar. Always use responsive sm:max-w-* classes.
Standard Dialog Structure
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> {/* or max-w-2xl, max-w-4xl */}
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>
Optional description providing context.
</DialogDescription>
</DialogHeader>
{/* Body content */}
<div className="space-y-4 py-4">
{/* Form fields, confirmation text, etc. */}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isLoading}>
{isLoading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Primary Action
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Dialog Header
<DialogHeader>
<DialogTitle>
{/* text-lg font-semibold — handled by shadcn */}
{title}
</DialogTitle>
<DialogDescription>
{/* text-sm text-muted-foreground — handled by shadcn */}
{description}
</DialogDescription>
</DialogHeader>
| Element | Shadcn Class Applied |
|---|---|
DialogTitle | text-lg font-semibold leading-none tracking-tight |
DialogDescription | text-sm text-muted-foreground |
Do not add additional classes to these elements unless overriding alignment.
Dialog Footer — CTA Placement
The primary action is always last (rightmost). Cancel is always first (leftmost).
<DialogFooter className="gap-2 sm:gap-0">
{/* Cancel — outline, leftmost */}
<Button variant="outline" onClick={handleCancel}>Cancel</Button>
{/* Secondary action — optional, between Cancel and Primary */}
<Button variant="ghost" onClick={handleSecondary}>Save Draft</Button>
{/* Primary action — rightmost, default variant */}
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
| Button | Variant | Position |
|---|---|---|
| Cancel / Close | outline | Leftmost |
| Secondary action | ghost or secondary | Middle |
| Primary / destructive | default or destructive | Rightmost |
Confirmation Dialogs (Destructive)
For irreversible actions (delete, deactivate):
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
This will permanently delete <strong>{user.name}</strong> and all associated data.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Delete User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Rules for destructive dialogs:
- Primary CTA uses
variant="destructive" - Description must explain what will be deleted and that it cannot be undone
- Never pre-select the destructive option
- Always require an explicit click (no auto-confirm)
Form Dialogs
For single-entity create/edit forms:
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit User' : 'Add User'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update user information.' : 'Create a new user account.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-4">
{/* Single column on mobile, 2-col grid on md+ */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Full Name *</Label>
<Input id="name" {...register('name')} />
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input id="email" type="email" {...register('email')} />
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
{isEdit ? 'Save Changes' : 'Create User'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
Scrollable Body Content
When dialog content might exceed the viewport:
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>...</DialogHeader>
{/* Scrollable middle section */}
<div className="flex-1 overflow-y-auto py-4">
{/* Long form content */}
</div>
<DialogFooter>...</DialogFooter>
</DialogContent>
| Property | Value |
|---|---|
| Max height | max-h-[90vh] |
| Overflow control | overflow-hidden on content, overflow-y-auto on body |
| Layout | flex flex-col to make body flex-1 |
Use this pattern for: import wizards, complex forms, record detail dialogs, multi-step dialogs.
Alert Dialog (Blocking Confirmation)
For critical confirmations that block all interaction until resolved:
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the record.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Use AlertDialog (not Dialog) when the user cannot dismiss by clicking outside or pressing Escape — they must make a choice.
Loading State Within Dialogs
{/* Skeleton loading state while dialog data loads */}
{isLoading ? (
<div className="space-y-4 py-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-3/4" />
</div>
) : (
<div className="space-y-4 py-4">
{/* Actual form */}
</div>
)}
Error State Within Dialogs
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
Form Field Standards (Inside Dialogs)
| Element | Spec |
|---|---|
| Label | <Label> component + text-sm font-medium |
| Required indicator | * in label text (never use HTML required alone) |
| Input | <Input> — full width by default |
| Error message | <p className="text-xs text-destructive"> below the input |
| Field group | <div className="space-y-2"> wrapping Label + Input + error |
| Multi-field layout | <div className="grid gap-4 md:grid-cols-2"> |
Opening Dialogs
Dialogs are controlled by useState in the parent:
const [open, setOpen] = useState(false);
// Trigger:
<Button onClick={() => setOpen(true)}>Add Record</Button>
// Dialog:
<MyDialog open={open} onOpenChange={setOpen} />
Never use open={true} with no state — always wire to a state variable.
Violation Checklist
- Uses shadcn
Dialog— never custom modal implementation - Size uses
sm:max-w-*(not fixedw-[Npx]) -
DialogHeadercontainsDialogTitleand optionalDialogDescription -
DialogFooterhas Cancel (outline) leftmost, Primary (default) rightmost - Destructive actions use
variant="destructive"on primary button - Long content uses
max-h-[90vh] overflow-hidden flex flex-col+flex-1 overflow-y-auto - Loading state shows
animate-spinonRefreshCwicon in primary button - Form fields wrapped in
<div className="space-y-2">with Label + Input + error - Multi-field forms use
grid gap-4 md:grid-cols-2for 2-col layout - Error alerts use shadcn
Alert variant="destructive"