Skip to documentation content

Button

Trigger an action, submit a form, or open another control.

button-demo

Overview

Use a button for actions: submitting a form, opening a menu, starting a task, or confirming a choice. Use a link when the person is navigating to another page or view. When a link needs to look like a button, render it through Button so the visual style is shared while the semantics stay correct. See Don't do this for the most common way this goes wrong.

Anatomy

A button has a visible label, an optional icon, a variant, a size, and a state. The label names the action directly: prefer Save changes, Invite member, or Delete project over vague labels like Submit or Continue when you know what happens next. Specific labels also survive translation and screen-reader link lists better than generic ones.

Variants

Use one default button per view for the primary action. Use secondary or outline for supporting actions, ghost for low-emphasis controls in toolbars and cards, and link for inline actions inside text. Reserve destructive and destructive-outline for actions that are hard to reverse, such as deleting data. A destructive action that is merely prominent is not a destructive action; it must actually destroy something.

Appearances

Use the appearance prop to change a button's surface treatment without changing its semantic variant or color: flat keeps the original treatment, glossy adds a polished sheen, glow lights the surface from within, and bevel makes it feel like an extruded key. Appearances compose with every variant, so variant="destructive" appearance="glossy" stays destructive while gaining depth. Treat appearances as emphasis for hero surfaces, not as a replacement for variants; they change how heavy a button looks, never what it does.

Behavior

Loading. While work runs in the background, keep the label stable and show a spinner next to it. The label is the only thing telling people which action is pending; swapping it to Loading… removes that information and shifts layout at the same time. Disable the button while pending so duplicate submissions cannot fire.

Disabled. Disabled buttons are skipped by keyboard focus and screen readers do not announce why they are unavailable. Use disabled for actions that never apply in the current state, and pair it with nearby text that explains why when the reason is not obvious from the page.

Async actions. After a click triggers server work, disable immediately, keep the original label with a spinner, and report the outcome in surrounding text. Do not close menus or dialogs until the operation succeeds or fails.

Accessibility

Buttons receive keyboard focus and activate with both Enter and Space. The focus ring appears only for keyboard focus (focus-visible), so mouse users do not see it and keyboard users always do.

On devices with coarse pointers, such as phones, an invisible layer expands every button's hit area to at least 44 Ă— 44 px even when the visual control is smaller. Small icon buttons remain easy to tap without extra wrappers.

Icon-only buttons have no text content, so give each one an aria-label. Buttons are announced by their text content; decorative icons should be marked aria-hidden.

Long labels never wrap. The button grows to fit its label on one line. Keep labels short enough to fit their container, or truncate deliberately with max-w-full and the truncate utility on the label span.

In right-to-left layouts, spacing and order mirror automatically because the component uses logical flexbox spacing. Directional arrow icons do not flip on their own; choose direction-neutral icons or flip them intentionally.

Colors come from theme tokens, so buttons adapt to dark mode automatically. The glossy, glow, and bevel appearances overlay fixed light and dark gradients tuned against accent surfaces; re-check legibility if you place them on unusual backgrounds.

Installation

npx honestui@latest add button

Usage

import { Button } from "@/components/ui/button";
<Button>Publish</Button>

The native type defaults to "button" so stray buttons can never accidentally submit a form. Set type="submit" explicitly on the button that submits.

Don't do this

// Bad
<Button onClick={() => router.push("/settings")}>
  Settings
</Button>
// Good
<Button render={<Link href="/settings" />}>
  Settings
</Button>

The bad version looks like a link to no one. Browsers treat it as a button: it cannot be opened in a new tab with middle-click or Cmd click, the URL does not appear on hover, and assistive technology announces a button instead of a link, so people expect something different to happen. The render prop renders a real anchor element with button styling, which keeps navigation semantics intact.

Swapping the label while loading

// Bad
<Button disabled={pending}>
  {pending ? "Loading…" : "Save changes"}
</Button>
// Good
<Button disabled={pending}>
  {pending && <LoaderCircleIcon className="animate-spin" aria-hidden="true" />}
  Save changes
</Button>

Replacing the label erases the only information about what is happening, changes the button's width mid-interaction, and makes several stacked buttons indistinguishable while all of them say Loading….

Icon-only buttons without a name

// Bad
<Button size="icon">
  <TrashIcon />
</Button>
// Good
<Button size="icon" aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</Button>

An icon-only button has no accessible name, so screen readers announce just "button" and voice-control users have nothing to say to activate it. Name every icon-only control.

Examples

Surface appearances

Use appearance sparingly. It changes visual depth, not the action's meaning or priority.

button-appearances

Loading state

The label stays stable while the spinner communicates progress.

button-loading

Async action

Disable during the request, restore after it settles, and report the result outside the button.

button-async-action

Form submission

type="submit" is explicit here; the ghost Cancel button demonstrates why the default type="button" matters. Pressing Enter in the field submits too.

button-form-submit

Long labels

Labels grow rather than wrap. Prefer a shorter verb-first label like the second button.

button-long-text

Right-to-left languages

Spacing and order mirror automatically under dir="rtl".

button-rtl

API reference

Button accepts native button props and Base UI's render composition prop. Honest UI adds:

PropValuesDefault
variantdefault, outline, secondary, destructive, destructive-outline, ghost, linkdefault
sizexs, sm, default, lg, xl, icon, icon-sm, icon-lgdefault
appearanceflat, glossy, glow, bevelflat
asChildbooleanfalse

The native default is type="button"; set type="submit" explicitly inside a form. With render or asChild, the type attribute is left off so the rendered element decides its own semantics.