Button
Triggers an action. The most important interactive element in any interface, and the easiest one to overuse.
import { ArrowRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonDemo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline">Cancel</Button>
<Button>
Continue
<ArrowRightIcon data-icon="inline-end" />
</Button>
</div>
)
}Anatomy
- 1ContainerCarries the variant's fill, border and focus ring. Heights align with inputs.
- 2Leading iconOptional. Sized and spaced by the button; padding tightens on this side.
- 3LabelA verb that names the outcome, in sentence case.
- 4Trailing iconOptional. Signals direction or a menu.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/button.jsonUsage
import { Button } from "@/components/ui/button"<Button variant="outline">Cancel</Button>
<Button>Save changes</Button>Examples
Variants
Seven variants express hierarchy, not decoration. prfct's primary button is monochrome by design: color is reserved for meaning — selection, focus, status — so the one colored action on a surface is always the most important one.
import { Button } from "@/components/ui/button"
export default function ButtonVariants() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Button>Primary</Button>
<Button variant="brand">Brand</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="link">Link</Button>
</div>
)
}Sizes
Buttons come in five heights: xs 28, sm 32, default 36, lg 40 and xl 48 pixels. Inputs and selects share sm, default and lg, so controls on one row always align. Icon-only buttons are square at every size.
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonSizes() {
return (
<div className="flex flex-col items-center gap-6">
<div className="flex flex-wrap items-center justify-center gap-3">
<Button size="xs">Extra small</Button>
<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">Large</Button>
<Button size="xl">Extra large</Button>
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button size="icon-xs" variant="outline" aria-label="Add item">
<PlusIcon />
</Button>
<Button size="icon-sm" variant="outline" aria-label="Add item">
<PlusIcon />
</Button>
<Button size="icon" variant="outline" aria-label="Add item">
<PlusIcon />
</Button>
<Button size="icon-lg" variant="outline" aria-label="Add item">
<PlusIcon />
</Button>
</div>
</div>
)
}With icons
Mark icons with data-icon="inline-start" or data-icon="inline-end". The button tightens its padding on that side so the optical balance stays even, and sizes the icon for you — never add sizing classes to icons inside a button.
import { DownloadIcon, ExternalLinkIcon, SendIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export default function ButtonWithIcon() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Button>
<SendIcon data-icon="inline-start" />
Send invite
</Button>
<Button variant="outline">
<DownloadIcon data-icon="inline-start" />
Export CSV
</Button>
<Button variant="ghost">
Open in new tab
<ExternalLinkIcon data-icon="inline-end" />
</Button>
</div>
)
}<Button>
<SendIcon data-icon="inline-start" />
Send invite
</Button>Icon only
An icon-only button has no visible text, so it must have an aria-label. Pair it with a tooltip that repeats the label for sighted mouse users.
import { CopyIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
const actions = [
{ label: "Edit", icon: PencilIcon },
{ label: "Duplicate", icon: CopyIcon },
{ label: "Delete", icon: Trash2Icon },
]
export default function ButtonIconOnly() {
return (
<div className="flex items-center gap-1">
{actions.map(({ label, icon: Icon }) => (
<Tooltip key={label}>
<TooltipTrigger
render={<Button variant="ghost" size="icon" aria-label={label} />}
>
<Icon />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
))}
</div>
)
}Loading
loading replaces the label with a spinner without changing the button's width, sets aria-busy, and blocks clicks — but keeps keyboard focus on the button, so screen reader and keyboard users don't lose their place.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
export default function ButtonLoading() {
const [saving, setSaving] = React.useState(false)
function save() {
setSaving(true)
setTimeout(() => setSaving(false), 2000)
}
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Button loading={saving} onClick={save}>
Save changes
</Button>
<Button variant="outline" loading>
Publishing
</Button>
</div>
)
}loading preserves both. You can still compose a Spinner yourself when you need a different layout.As a link
An action that navigates is a link, even when it looks like a button: screen readers announce it as a link, and people can open it in a new tab. Style the link with buttonVariants rather than rendering a Button as an anchor, which Base UI would give role="button".
import { ArrowUpRightIcon } from "lucide-react"
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
export default function ButtonAsLink() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Link href="/docs/installation" className={buttonVariants()}>
Get started
</Link>
<a
href="https://base-ui.com"
target="_blank"
rel="noreferrer"
className={buttonVariants({ variant: "outline" })}
>
Base UI docs
<ArrowUpRightIcon data-icon="inline-end" />
</a>
</div>
)
}import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
<Link href="/docs/installation" className={buttonVariants()}>
Get started
</Link>Guidelines
When to use
- To trigger an action on the current page: submit a form, open a dialog, save, delete.
- To start a flow: Create project, Invite teammate.
When not to use
- To navigate. Use a link — styled with
buttonVariantswhen it should look like a button. - To toggle a setting on and off — use a Switch.
- To choose one option from a small set — use a Toggle Group.
Hierarchy
Give each view one primary action. Everything else steps down to outline, secondary or ghost. Place the primary action last in a row (right-aligned in footers), and put dismissive actions like Cancel on the far side.
import { Button } from "@/components/ui/button"
export default function ButtonHierarchy() {
return (
<div className="flex w-full max-w-md items-center justify-between gap-3 rounded-xl border bg-card p-4 shadow-raised">
<Button variant="ghost">Discard</Button>
<div className="flex items-center gap-2">
<Button variant="outline">Save draft</Button>
<Button>Publish</Button>
</div>
</div>
)
}Labels
Lead with a verb and name the outcome: Save changes, Delete project, Send 3 invites. Use sentence case, keep it to one to three words, and never end with punctuation. A label should still make sense when read out of context by a screen reader.
Accessibility
prfct's button renders a native <button> through Base UI, so it is focusable, announces its role, and activates with Enter and Space without extra work.
| Key | Behavior |
|---|---|
Tab | Moves focus to the button. The focus ring is a 2px outline offset from the edge, visible in every theme and in forced-colors mode. |
EnterSpace | Activates the button. |
- Target size. Every size meets the 24×24px minimum of WCAG 2.2 (2.5.8): the smallest,
xsandicon-xs, are 28px. Keep those two for dense, pointer-first UI, because a 28px target is easy to miss with a finger. - Contrast. Label text on every filled variant meets 4.5:1 in both modes; see Color.
- Disabled. Disabled buttons are removed from the tab order. If people need to discover why an action is unavailable, keep the button enabled and explain on click, or use
focusableWhenDisabledwith a tooltip. - Loading.
loadingsetsaria-busy="true"and keeps focus; pair it with a polite live region or toast that announces the result.
API reference
Button
Renders a <button> element. Accepts every prop of the Base UI Button. The props type is exported as ButtonProps, for components that wrap the button.
buttonVariants
The class generator is exported so other elements — above all, links — can look like buttons without being buttons. Icons marked with data-icon are sized and spaced exactly as in a Button.
import { buttonVariants } from "@/components/ui/button"
<Link href="/pricing" className={buttonVariants({ variant: "outline", size: "sm" })}>
Pricing
</Link>