Button
Primary interactive primitive. Located at apps/web/components/ui/button.tsx.
Variants
| Variant | Use |
|---|---|
| primary | Main CTA. Accent fill, white text. At most 1-2 per screen. |
| ghost | Secondary action. No fill, inherits text color, hover surface. |
| icon-only | Square icon button. Used in toolbars, topbars. |
Sizes
| Size | Height | Usage |
|---|---|---|
| sm | 32px | Topbar actions, row inline actions |
| default | 40px | Form submit, modal footer |
Examples
<Button variant="primary">Save changes</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="primary" size="sm">
<IconPlus width={14} height={14} /> Add user
</Button>
<Button variant="icon-only" size="sm" aria-label="Settings">
<SettingsIcon />
</Button>
Pairing with icons
When a button has text + icon, put the icon first, use width={14} height={14} for sm and width={16} height={16} for default. The button component already handles the gap via flex.
Loading state
Disabled while submitting:
<Button variant="primary" disabled={saving}>
{saving ? "Saving…" : "Save changes"}
</Button>
No spinner by default — the text change is enough. Add a spinner only if the wait exceeds ~500ms consistently.
Destructive actions
Use variant="primary" + className="bg-error hover:bg-error/90":
<Button
variant="primary"
className="bg-error hover:bg-error/90"
onClick={handleDelete}
>
Delete project
</Button>
Pair with window.confirm() or a confirmation modal. Never let a single click destroy data.
Don't
- Don't add
rounded-fullto a non-primary button — only the primary button gets the pill shape. - Don't use
variant="ghost"as a primary CTA — it disappears against the surface. - Don't change the button height with
h-*— use thesizeprop. - Don't mix icon + text in an
icon-onlybutton — use default with icon+text or a<Chip>if you need a tag-like element.