Skip to main content

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

VariantUse CaseExample
defaultPrimary action — one per page/section"Save", "Create Booking", "Submit"
secondarySecondary alternative action"Save as Draft", "Export"
outlineTertiary neutral action"Edit", "View Details", "Download"
ghostLow-emphasis, contextualCancel, toolbar icon buttons, nav items
destructiveIrreversible destructive action"Delete", "Cancel Booking"
linkInline text link behavior"Learn more", "View docs"

Rule: Never use default (primary) for Cancel. Never use destructive for non-destructive actions.


Button Sizes

SizeClassUse
defaulth-10 px-4Standard form actions, page-level CTAs
smh-9 px-3Toolbar area, compact forms, secondary page actions
lgh-11 px-8Prominent CTAs (login, checkout)
iconh-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-auto or at far left with flex-1 spacer
<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 Tooltip explaining 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-label or sr-only span for screen readers
  • Always have Tooltip showing the action label
  • Header buttons: h-9 w-9 with h-4 w-4 icon
  • Toolbar buttons: h-8 w-8 with h-4 w-4 icon

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-label and Tooltip
  • Delete always requires AlertDialog confirmation
  • 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