Component · Core
Button
Overview
The default interactive trigger for a single user action — submit a form, confirm a dialog, navigate, or run a command.
Anatomy
Container → optional leading icon → label → optional trailing icon.
- Container — the native
<button>surface that carries the variant and size. - Leading icon — optional; sits before the label.
- Label — the action text.
- Trailing icon — optional; sits after the label.
Two configurations change which parts render:
- Icon-only (
iconsize) drops the label entirely — the icon is centered in a square button. - Loading (
loadingprop) swaps the leading icon slot for a spinner and disables interaction; the label may stay visible or hide, depending on space.
Live Example
Variants
Sizes
Icons & states
Code Example
import { ArrowRight, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
const VARIANTS = [
"primary",
"secondary",
"destructive",
"outline",
"ghost",
"link",
] as const;
/**
* Live Button example — the documented variant / size / state matrix, rendered
* with the real component so the page doubles as a visual check.
*/
export function ButtonShowcase() {
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Variants
</h3>
<div className="flex flex-wrap items-center gap-3">
{VARIANTS.map((variant) => (
<Button key={variant} variant={variant}>
{variant[0].toUpperCase() + variant.slice(1)}
</Button>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Sizes
</h3>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon" aria-label="Add" leadingIcon={<Plus />} />
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Icons & states
</h3>
<div className="flex flex-wrap items-center gap-3">
<Button leadingIcon={<Plus />}>Leading icon</Button>
<Button variant="secondary" trailingIcon={<ArrowRight />}>
Trailing icon
</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
<Button variant="outline" loading leadingIcon={<Plus />}>
Saving
</Button>
</div>
</section>
</div>
);
}Variants
Six variants. Every value below is an existing semantic token — see Color.
| Variant | Background | Text | Border |
|---|---|---|---|
| Primary | --primary | --primary-foreground | none |
| Secondary | --secondary | --secondary-foreground | none |
| Destructive | --destructive | --destructive-foreground | none |
| Outline | --background | --foreground | --border |
| Ghost | transparent (--accent on hover) | --foreground | none |
| Link | transparent | --primary | none, underline on hover |
Use Primary for the one main action per view. Secondary, Outline, and Ghost step down in visual weight for supporting actions, in that order. Destructive replaces Primary only when the action is irreversible or dangerous. Link reads as navigation, not a button — reserve it for actions that behave like a link.
Sizes
Four sizes. Height and padding-x are scale steps; icon size draws from Icons.
| Size | Height | Padding-x | Icon size |
|---|---|---|---|
| sm | --scale-8 (32px) | --scale-2 (8px) | --scale-4 (16px) |
| default | --scale-10 (40px) | --scale-3 (12px) | --scale-4 (16px) |
| lg | --scale-12 (48px) | --scale-4 (16px) | --scale-5 (20px) |
| icon | --scale-10 square | — | --scale-5 (20px) |
- Icon-to-label gap:
--scale-1(4px) — the scale's own "icon-to-label gap" use. - Corner radius:
--radius-mdfor every size — the Radius table already names "Buttons" for this token. - Label type:
text-sm font-medium leading-snug— Typography's "Label / button" recipe, used as-is.
Why 40px default, not shadcn's usual 36px? 36px isn't a step on the master scale (steps 7, 9, 11… are skipped). 40px (
--scale-10) is the nearest step, so Button's default height stays on the shared ruler — same reasoning as Radius choosing an 8px base over shadcn's 10px.
States
| State | Look |
|---|---|
| Default | Variant colors as above |
| Hover | Slight background shift (--accent for Ghost/Outline; a touch darker/lighter for filled variants) |
| Focus-visible | Ring in --ring on :focus-visible (keyboard focus only) — offset geometry: a --scale-0-5 (2px) ring set a --scale-0-5 (2px) gap outside the button edge, tracing --radius-md. See Color — Border width; the flush field ring (Input) is the system's other geometry |
| Active | Slight background shift, one step further than hover |
| Disabled | Reduced opacity (opacity-50; Color — Disabled dimming), no pointer events, no focus ring |
| Loading | Spinner replaces the leading icon slot; aria-busy="true"; disabled interaction |
Usage Guidance
Tokens
Button uses existing Monogem semantic and primitive tokens to stay consistent with the design system; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--primary / --primary-foreground | Primary variant |
--secondary / --secondary-foreground | Secondary variant |
--destructive / --destructive-foreground | Destructive variant |
--background / --foreground | Outline variant |
--accent / --accent-foreground | Ghost hover, Outline hover |
--border | Outline variant border |
--ring | Focus-visible ring, all variants |
--radius-md | Corner radius, all sizes |
--scale-8 / --scale-10 / --scale-12 | Heights: sm / default+icon / lg |
--scale-2 / --scale-3 / --scale-4 | Padding-x: sm / default / lg |
--scale-1 | Icon-to-label gap |
--scale-4 / --scale-5 | Icon size: sm+default / lg+icon (Icons foundation recipe, no dedicated icon-size token exists) |
Do / Don’t
Do
- Default to Primary for the one main action per view.
- Use Secondary, Outline, or Ghost for supporting actions — pick by how much visual weight the action needs.
- Reserve Destructive for irreversible or dangerous actions.
- Pair every icon-only button with
aria-label.
Don’t
- Use Link for anything that isn't navigation-like — it reads as a hyperlink, not a button.
- Stack more than one Primary button in the same view — it dilutes the "main action" signal.
- Hard-code a size or color instead of using the variant/size props above.
Accessibility
- Icon-only buttons require
aria-label— there's no visible label to fall back on. - Focus ring shows on
:focus-visibleonly (keyboard focus), not on mouse click. - Disabled uses the native
disabledattribute — removes the button from the tab order and the accessibility tree's actionable elements automatically; no ARIA needed. - Loading sets
aria-busy="true"on the button while active.