Skip to main content

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

SizeClassMax WidthUse For
Smallsm:max-w-md448pxConfirmations, simple prompts
Mediumsm:max-w-2xl672pxSingle-form dialogs, detail views
Largesm:max-w-4xl896pxImport wizard, complex forms
Extra Largesm:max-w-6xl1152pxMulti-panel dialogs, data previews
Full-screen (mobile)w-full h-full rounded-noneScreenAny 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>
ElementShadcn Class Applied
DialogTitletext-lg font-semibold leading-none tracking-tight
DialogDescriptiontext-sm text-muted-foreground

Do not add additional classes to these elements unless overriding alignment.


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>
ButtonVariantPosition
Cancel / CloseoutlineLeftmost
Secondary actionghost or secondaryMiddle
Primary / destructivedefault or destructiveRightmost

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>
PropertyValue
Max heightmax-h-[90vh]
Overflow controloverflow-hidden on content, overflow-y-auto on body
Layoutflex 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)

ElementSpec
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 fixed w-[Npx])
  • DialogHeader contains DialogTitle and optional DialogDescription
  • DialogFooter has 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-spin on RefreshCw icon 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-2 for 2-col layout
  • Error alerts use shadcn Alert variant="destructive"