Skip to content

Button

Triggers an action. The most important interactive element in any interface, and the easiest one to overuse.

SourceBase UIRegistry
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

  1. 1ContainerCarries the variant's fill, border and focus ring. Heights align with inputs.
  2. 2Leading iconOptional. Sized and spaced by the button; padding tightens on this side.
  3. 3LabelA verb that names the outcome, in sentence case.
  4. 4Trailing iconOptional. Signals direction or a menu.

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/button.json

Usage

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>
  )
}
VariantUse it for
defaultThe single primary action of a view: Save, Create project, Continue.
brandA primary action that should carry the brand, typically on marketing surfaces.
secondarySupporting actions that sit next to a primary one.
outlineNeutral actions with a clear boundary: Cancel, Export, toolbar actions.
ghostLow-emphasis actions in dense UI: toolbars, table rows, card headers.
destructiveIrreversible actions: Delete, Revoke. Usually inside a confirmation.
linkNavigation that must look like text, inside sentences or footers.

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>
  )
}
Why a prop and not composition?
Swapping the label for a spinner by hand usually shrinks the button and drops focus when it becomes disabled. loading preserves both. You can still compose a Spinner yourself when you need a different layout.

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 buttonVariants when 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>
  )
}
DoOne primary action, supported by quieter ones.
Don’tSeveral primary buttons compete — nothing reads as most important.

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.

DoSpecific verbs tell people exactly what will happen.
Don’tGeneric answers force people to reread the question.

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.

KeyBehavior
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, xs and icon-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 focusableWhenDisabled with a tooltip.
  • Loading. loading sets aria-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.

PropTypeDefault
variant

Visual emphasis of the button.

"default" | "brand" | "secondary" | "outline" | "ghost" | "destructive" | "link""default"
size

Height and padding. Icon sizes render a square button.

"xs" | "sm" | "default" | "lg" | "xl" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "icon-xl""default"
loading

Shows a spinner, sets aria-busy and blocks activation while keeping width and focus.

booleanfalse
disabled

Disables the button and removes it from the tab order.

booleanfalse
focusableWhenDisabled

Keeps a disabled button focusable, e.g. to show a tooltip explaining why.

booleanfalse
nativeButton

Set to false when render produces a non-button element; Base UI then adds role="button" and keyboard activation. For navigation, use a link with buttonVariants instead.

booleantrue
render

Replaces the rendered element while keeping behavior and styles.

ReactElement | (props, state) => ReactElementNo default

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>