UX Standards — 30: Button and Action Hierarchy
Governs: All buttons, CTAs, and interactive actions — variants, placement, states, and confirmation patterns.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Button Variants
| Variant | Use Case | Example |
|---|---|---|
default | Primary action — one per page/section | "Save", "Create Booking", "Submit" |
secondary | Secondary alternative action | "Save as Draft", "Export" |
outline | Tertiary neutral action | "Edit", "View Details", "Download" |
ghost | Low-emphasis, contextual | Cancel, toolbar icon buttons, nav items |
destructive | Irreversible destructive action | "Delete", "Cancel Booking" |
link | Inline text link behavior | "Learn more", "View docs" |
Rule: Never use default (primary) for Cancel. Never use destructive for non-destructive actions.
Button Sizes
| Size | Class | Use |
|---|---|---|
default | h-10 px-4 | Standard form actions, page-level CTAs |
sm | h-9 px-3 | Toolbar area, compact forms, secondary page actions |
lg | h-11 px-8 | Prominent CTAs (login, checkout) |
icon | h-9 w-9 (header) / h-8 w-8 (toolbar) | Icon-only buttons |
CTA Placement Rules
Form Actions
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="ghost" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{t('common.save')}
</Button>
</div>
- Primary action rightmost
- Cancel left of Save,
variant="ghost" - Destructive action separated by
ml-autoor at far left withflex-1spacer
Modal Footer
<DialogFooter>
{/* Destructive always far left if present */}
{canDelete && (
<Button variant="destructive" onClick={onDelete} className="mr-auto">
{t('common.delete')}
</Button>
)}
<Button variant="ghost" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={onConfirm}>{t('common.confirm')}</Button>
</DialogFooter>
Toolbar Actions
<div className="flex items-center gap-1">
{/* Primary action leftmost in toolbar */}
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
{t('booking.create')}
</Button>
{/* Icon buttons grouped right */}
<div className="ml-auto flex items-center gap-1">
<DataTableIconButton icon={RefreshCw} tooltip={t('common.refresh')} onClick={refetch} />
<DataTableIconButton icon={Download} tooltip={t('common.export')} onClick={onExport} />
</div>
</div>
Loading State on Async Buttons
function SaveButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) {
return (
<Button onClick={onClick} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('common.saving')}
</>
) : (
t('common.save')
)}
</Button>
);
}
Rules:
disabled={isLoading}— always disable during async operation- Replace icon with
Loader2 animate-spin— don't add spinner ALONGSIDE existing icon - Change label: "Save" → "Saving…", "Delete" → "Deleting…", "Export" → "Exporting…"
- Never leave button loading indefinitely — always resolve on success or error
Disabled State
<Tooltip>
<TooltipTrigger asChild>
{/* span wrapper needed because disabled button doesn't trigger tooltip */}
<span tabIndex={0}>
<Button disabled className="pointer-events-none">
<Lock className="h-4 w-4 mr-2" />
{t('common.edit')}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t('booking.editLockedReason')}</TooltipContent>
</Tooltip>
Rules:
- Disabled buttons MUST have a
Tooltipexplaining WHY they're disabled - Wrap in
<span tabIndex={0}>so tooltip still triggers on hover - Never show a disabled button without an explanation — use hide instead if there's no explanation
Icon-Only Buttons
<Button variant="ghost" size="icon" className="h-9 w-9" aria-label={t('common.settings')}>
<Settings className="h-4 w-4" />
<span className="sr-only">{t('common.settings')}</span>
</Button>
Rules:
- Always have
aria-labelorsr-onlyspan for screen readers - Always have
Tooltipshowing the action label - Header buttons:
h-9 w-9withh-4 w-4icon - Toolbar buttons:
h-8 w-8withh-4 w-4icon
Destructive Action Pattern
Simple Delete
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('booking.confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('booking.confirmDeleteDescription', { name: record.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Danger Zone (Settings Pages)
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">{t('settings.dangerZone')}</CardTitle>
<CardDescription>{t('settings.dangerZoneDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">{t('settings.deleteAccount')}</p>
<p className="text-xs text-muted-foreground">{t('settings.deleteAccountWarning')}</p>
</div>
<DeleteAccountDialog />
</CardContent>
</Card>
Button Group Pattern
For mutually exclusive actions (e.g., view mode toggle):
<div className="flex rounded-md border border-border overflow-hidden">
{['list', 'grid', 'kanban'].map((mode) => (
<Button
key={mode}
variant={viewMode === mode ? 'default' : 'ghost'}
size="sm"
className="rounded-none border-0"
onClick={() => setViewMode(mode)}
>
{mode === 'list' && <List className="h-4 w-4" />}
{mode === 'grid' && <LayoutGrid className="h-4 w-4" />}
</Button>
))}
</div>
Violation Checklist
-
variant="ghost"for Cancel — never "outline" or "secondary" - Primary action rightmost in form/modal footers
- Destructive buttons separated (far left) from constructive buttons
- Loading buttons:
disabled+Loader2 animate-spin+ text change - Disabled buttons have Tooltip explaining why
- Icon-only buttons have
aria-labelandTooltip - Delete always requires
AlertDialogconfirmation - Danger zone wrapped in
Card className="border-destructive/50"on settings pages - Header icon buttons:
h-9 w-9| Toolbar icon buttons:h-8 w-8 - Never
variant="default"(primary) for Cancel, Back, or secondary actions - All button labels go through
t()— no hardcoded English