Skip to content
Monogem

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 (icon size) drops the label entirely — the icon is centered in a square button.
  • Loading (loading prop) 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

button-showcase.tsx
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 &amp; 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.

VariantBackgroundTextBorder
Primary--primary--primary-foregroundnone
Secondary--secondary--secondary-foregroundnone
Destructive--destructive--destructive-foregroundnone
Outline--background--foreground--border
Ghosttransparent (--accent on hover)--foregroundnone
Linktransparent--primarynone, 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.

SizeHeightPadding-xIcon 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-md for 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

StateLook
DefaultVariant colors as above
HoverSlight background shift (--accent for Ghost/Outline; a touch darker/lighter for filled variants)
Focus-visibleRing 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
ActiveSlight background shift, one step further than hover
DisabledReduced opacity (opacity-50; Color — Disabled dimming), no pointer events, no focus ring
LoadingSpinner 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.

TokenWhere used
--primary / --primary-foregroundPrimary variant
--secondary / --secondary-foregroundSecondary variant
--destructive / --destructive-foregroundDestructive variant
--background / --foregroundOutline variant
--accent / --accent-foregroundGhost hover, Outline hover
--borderOutline variant border
--ringFocus-visible ring, all variants
--radius-mdCorner radius, all sizes
--scale-8 / --scale-10 / --scale-12Heights: sm / default+icon / lg
--scale-2 / --scale-3 / --scale-4Padding-x: sm / default / lg
--scale-1Icon-to-label gap
--scale-4 / --scale-5Icon 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-visible only (keyboard focus), not on mouse click.
  • Disabled uses the native disabled attribute — 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.