# Button

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

Source: https://www.honestui.com/docs/components/button

```tsx
import Link from "next/link";
import { Check, Plus, Trash } from "honestui/icons";

import { Button } from "@/components/honest-ui/ui/button";

export function ButtonDemo() {
  return (
    <div className="grid gap-5">
      <div className="flex flex-wrap items-center gap-3">
        <Button>
          <Check aria-hidden="true" />
          Publish
        </Button>
        <Button variant="secondary">Save draft</Button>
        <Button variant="outline">Preview</Button>
        <Button variant="ghost">Cancel</Button>
        <Button variant="destructive">
          <Trash aria-hidden="true" />
          Delete
        </Button>
      </div>
      <div className="flex flex-wrap items-center gap-3">
        <Button size="xs">Extra small</Button>
        <Button size="sm">Small</Button>
        <Button>Default</Button>
        <Button size="lg">Large</Button>
        <Button size="icon" aria-label="Add item">
          <Plus aria-hidden="true" />
        </Button>
        <Button disabled>Unavailable</Button>
        <Button render={<Link href="/docs/get-started" />}>Get started</Button>
      </div>
    </div>
  );
}

```

## Overview [#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](#dont-do-this) for the most common way this goes wrong.

## Anatomy [#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 [#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 [#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 [#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 [#accessibility]

Buttons receive keyboard focus and activate with both <kbd>Enter</kbd> and <kbd>Space</kbd>. 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 [#installation]


  

  
    <CliBlock commands="[&#x22;button&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Import the following variables into your CSS file
      

      ```css
      @theme inline {
        --color-destructive-foreground: var(--destructive-foreground);
      }

      :root {
        --destructive-foreground: oklch(0.505 0.213 27.518);
      }

      .dark {
        --destructive-foreground: oklch(0.704 0.191 22.216);
      }
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/button.tsx

```tsx
import * as React from "react"
import { mergeProps } from "@base-ui-components/react/merge-props"
import { useRender } from "@base-ui-components/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "relative inline-flex w-fit shrink-0 cursor-pointer items-center justify-center gap-[var(--hui-space-3)] whitespace-nowrap rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--hui-space-4)] py-[var(--hui-space-3)] [font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)] outline-none motion-safe:[transition:var(--hui-transition-interactive)] motion-safe:active:[transition:var(--hui-transition-pressed)] focus-visible:[outline:var(--hui-focus-ring)] not-disabled:active:scale-[var(--hui-scale-pressed)] disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:after:pointer-events-none pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default:
          "bg-[var(--hui-color-background-accent-emphasis)] text-[var(--hui-color-foreground-accent-emphasis)] shadow-[var(--hui-shadow-feather)] hover:bg-[var(--hui-color-background-accent-emphasis-hover)] active:bg-[var(--hui-color-background-accent-emphasis-hover)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-accent)] disabled:hover:bg-[var(--hui-color-background-accent-emphasis)]",
        outline:
          "border-[0.5px] border-[var(--hui-color-border-accent-emphasis)] bg-[var(--hui-color-background-base-primary)] text-[var(--hui-color-foreground-accent-primary)] shadow-[var(--hui-shadow-feather)] hover:bg-[var(--hui-color-background-accent-primary)] active:bg-[var(--hui-color-background-accent-primary)] disabled:bg-transparent disabled:hover:border-[var(--hui-color-border-accent-emphasis)] disabled:hover:bg-transparent disabled:hover:text-[var(--hui-color-foreground-accent-primary)]",
        secondary:
          "bg-[var(--hui-color-background-neutral-secondary)] text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-feather)] hover:bg-[var(--hui-color-background-neutral-secondary-hover)] active:bg-[var(--hui-color-background-neutral-secondary-hover)] disabled:hover:bg-[var(--hui-color-background-neutral-secondary)] disabled:hover:text-[var(--hui-color-foreground-base-primary)]",
        destructive:
          "bg-[var(--hui-color-background-danger-emphasis)] text-[var(--hui-color-foreground-danger-emphasis)] shadow-[var(--hui-shadow-feather)] hover:bg-[var(--hui-color-background-danger-emphasis-hover)] active:bg-[var(--hui-color-background-danger-emphasis-hover)] focus-visible:[outline-color:var(--hui-color-border-danger-emphasis)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-accent)] disabled:hover:bg-[var(--hui-color-background-danger-emphasis)]",
        "destructive-outline":
          "border-[0.5px] border-[var(--hui-color-border-danger-emphasis)] bg-[var(--hui-color-background-base-primary)] text-[var(--hui-color-foreground-danger-primary)] shadow-[var(--hui-shadow-feather)] hover:border-[var(--hui-color-border-danger-emphasis-hover)] hover:bg-[var(--hui-color-background-danger-primary)] active:border-[var(--hui-color-border-danger-emphasis-hover)] active:bg-[var(--hui-color-background-danger-primary)] focus-visible:[outline-color:var(--hui-color-border-danger-emphasis)] disabled:bg-transparent disabled:hover:border-[var(--hui-color-border-danger-emphasis)] disabled:hover:bg-transparent disabled:hover:text-[var(--hui-color-foreground-danger-primary)]",
        ghost:
          "border border-dashed border-[var(--hui-color-border-base-primary)] text-[var(--hui-color-foreground-base-primary)] hover:bg-[var(--hui-color-background-base-primary-hover)] active:bg-[var(--hui-color-background-base-primary-hover)] disabled:hover:border-[var(--hui-color-border-base-primary)] disabled:hover:bg-transparent disabled:hover:text-[var(--hui-color-foreground-base-primary)]",
        link: "text-[var(--hui-color-foreground-base-primary)] hover:bg-[var(--hui-color-background-base-primary-hover)] active:bg-[var(--hui-color-background-base-primary-hover)] disabled:hover:bg-transparent disabled:hover:text-[var(--hui-color-foreground-base-primary)]",
      },
      size: {
        default:
          "px-[var(--hui-space-4)] py-[var(--hui-space-3)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]",
        xs: "gap-[var(--hui-space-2)] rounded-[var(--hui-radius-1)] px-[var(--hui-space-2)] py-[var(--hui-space-1)] [font-size:var(--hui-font-size-micro)] [letter-spacing:var(--hui-letter-spacing-micro)] [line-height:var(--hui-line-height-micro)] [&_svg:not([class*='size-'])]:size-3",
        sm: "gap-[var(--hui-space-2)] px-[var(--hui-space-3)] py-[var(--hui-space-2)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]",
        lg: "px-[var(--hui-space-5)] py-[var(--hui-space-3)] [font-size:var(--hui-font-size-regular)] [letter-spacing:var(--hui-letter-spacing-regular)] [line-height:var(--hui-line-height-regular)]",
        xl: "px-[var(--hui-space-6)] py-[var(--hui-space-4)] [font-size:var(--hui-font-size-large)] [letter-spacing:var(--hui-letter-spacing-large)] [line-height:var(--hui-line-height-large)] [&_svg:not([class*='size-'])]:size-4.5",
        icon: "size-8 p-0",
        "icon-sm": "size-6 p-0",
        "icon-lg": "size-9 p-0",
      },
      appearance: {
        flat: null,
        glossy:
          "bg-[linear-gradient(180deg,rgba(255,255,255,.46)_0%,rgba(255,255,255,.12)_38%,transparent_39%),linear-gradient(180deg,rgba(255,255,255,.12)_0%,transparent_54%,rgba(0,0,0,.18)_100%)] shadow-[inset_0_1px_0_rgba(255,255,255,.5),inset_0_0_5px_rgba(255,255,255,.18),inset_0_-18px_15px_-14px_rgba(0,0,0,.35),0_0_0_.75px_rgba(0,0,0,.24),0_3px_8px_rgba(0,0,0,.24)]! [text-shadow:0_1px_2px_rgba(0,0,0,.35)] transition-[filter,box-shadow] duration-200 hover:brightness-[1.06]",
        glow:
          "bg-[linear-gradient(180deg,rgba(0,0,0,.18)_0%,rgba(255,255,255,.08)_100%)] shadow-[inset_0_-1.5px_2px_rgba(255,255,255,.45),inset_0_0_12px_rgba(255,255,255,.22),inset_0_0_8px_rgba(255,255,255,.18),0_2px_6px_rgba(0,0,0,.28)]! [text-shadow:0_1px_2px_rgba(0,0,0,.4)] transition-[filter,box-shadow] duration-200 hover:brightness-[1.12]",
        bevel:
          "bg-[linear-gradient(180deg,rgba(255,255,255,.2)_0%,transparent_100%)] shadow-[inset_0_1px_0_rgba(255,255,255,.4),0_1px_0_rgba(0,0,0,.28),0_2px_0_rgba(0,0,0,.28),0_3px_0_rgba(0,0,0,.38),0_4px_0_rgba(0,0,0,.38),0_6px_8px_rgba(0,0,0,.3)]! [text-shadow:0_1px_0_rgba(0,0,0,.4)] transition-[transform,box-shadow] duration-120 hover:-translate-y-px [&:is(:active,[data-pressed])]:translate-y-1 [&:is(:active,[data-pressed])]:shadow-[inset_0_1px_0_rgba(255,255,255,.35),0_1px_0_rgba(0,0,0,.38),0_2px_4px_rgba(0,0,0,.28)]!",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
      appearance: "flat",
    },
  }
)

interface ButtonProps extends useRender.ComponentProps<"button"> {
  variant?: VariantProps<typeof buttonVariants>["variant"]
  size?: VariantProps<typeof buttonVariants>["size"]
  appearance?: VariantProps<typeof buttonVariants>["appearance"]
  asChild?: boolean
}

function Button({
  className,
  variant,
  size,
  appearance,
  render,
  asChild = false,
  children,
  ...props
}: ButtonProps) {
  const renderValue = asChild
    ? (React.Children.only(children) as React.ReactElement<
        Record<string, unknown>
      >)
    : render

  const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>["type"] =
    renderValue ? undefined : "button"

  const defaultProps = {
    "data-slot": "button",
    className: cn(buttonVariants({ variant, size, appearance, className })),
    type: typeValue,
  }

  return useRender({
    defaultTagName: "button",
    render: renderValue,
    props: mergeProps<"button">(
      defaultProps,
      asChild ? props : { ...props, children }
    ),
  })
}

export { Button, buttonVariants }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { Button } from "@/components/ui/button";
```

```tsx
<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 [#dont-do-this]

### Navigating with onClick [#navigating-with-onclick]

```tsx
// Bad
<Button onClick={() => router.push("/settings")}>
  Settings
</Button>
```

```tsx
// 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 <kbd>Cmd</kbd> 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 [#swapping-the-label-while-loading]

```tsx
// Bad
<Button disabled={pending}>
  {pending ? "Loading…" : "Save changes"}
</Button>
```

```tsx
// 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 [#icon-only-buttons-without-a-name]

```tsx
// Bad
<Button size="icon">
  <TrashIcon />
</Button>
```

```tsx
// 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 [#examples]

### Surface appearances [#surface-appearances]

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

```tsx
import { Plus as PlusIcon } from "honestui/icons"

import { Button } from "@/components/honest-ui/ui/button"

export function ButtonAppearances() {
  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap items-center gap-4">
        <Button appearance="flat" size="xl">
          <PlusIcon />
          Flat
        </Button>
        <Button appearance="glossy" size="xl">
          <PlusIcon />
          Glossy
        </Button>
        <Button appearance="glow" size="xl">
          <PlusIcon />
          Glow
        </Button>
        <Button appearance="bevel" size="xl">
          <PlusIcon />
          Bevel
        </Button>
      </div>
      <div className="flex flex-wrap items-center gap-4">
        <Button appearance="flat" size="xl" variant="destructive">
          <PlusIcon />
          Flat
        </Button>
        <Button appearance="glossy" size="xl" variant="destructive">
          <PlusIcon />
          Glossy
        </Button>
        <Button appearance="glow" size="xl" variant="destructive">
          <PlusIcon />
          Glow
        </Button>
        <Button appearance="bevel" size="xl" variant="destructive">
          <PlusIcon />
          Bevel
        </Button>
      </div>
    </div>
  )
}

```

### Loading state [#loading-state]

The label stays stable while the spinner communicates progress.

```tsx
import { LoaderCircle as LoaderCircleIcon } from "honestui/icons"

import { Button } from "@/components/honest-ui/ui/button"

export function ButtonLoading() {
  return (
    <Button disabled>
      <LoaderCircleIcon className="animate-spin" />
      Loading...
    </Button>
  )
}

```

### Async action [#async-action]

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

```tsx
"use client";

import * as React from "react";
import { LoaderCircle as LoaderCircleIcon } from "honestui/icons";

import { Button } from "@/components/honest-ui/ui/button";

type SaveStatus = "idle" | "saving" | "saved";

export function ButtonAsyncAction() {
  const [status, setStatus] = React.useState<SaveStatus>("idle");

  React.useEffect(() => {
    if (status !== "saving") {
      return;
    }
    const timeout = setTimeout(() => setStatus("saved"), 1500);
    return () => clearTimeout(timeout);
  }, [status]);

  return (
    <div className="flex flex-col items-center gap-3">
      <Button
        disabled={status === "saving"}
        onClick={() => setStatus("saving")}
      >
        {status === "saving" ? (
          <LoaderCircleIcon className="animate-spin" aria-hidden="true" />
        ) : null}
        Save changes
      </Button>
      <p
        aria-live="polite"
        className="min-h-[var(--hui-space-5)] text-[length:var(--hui-font-size-mini)] text-muted-foreground"
      >
        {status === "saving" ? "Saving…" : status === "saved" ? "Changes saved." : null}
      </p>
    </div>
  );
}

```

### Form submission [#form-submission]

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

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/honest-ui/ui/button";
import { Field, FieldControl, FieldLabel } from "@/components/honest-ui/ui/field";
import { Form } from "@/components/honest-ui/ui/form";

export function ButtonFormSubmit() {
  const [submittedName, setSubmittedName] = React.useState("");

  return (
    <Form
      className="grid w-full max-w-64 gap-4"
      onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        const formData = new FormData(event.currentTarget);
        setSubmittedName(String(formData.get("name") ?? ""));
      }}
    >
      <Field>
        <FieldLabel>Display name</FieldLabel>
        <FieldControl name="name" placeholder="Ada Lovelace" required />
      </Field>
      <div className="flex gap-3">
        <Button type="submit">Create profile</Button>
        <Button variant="ghost">Cancel</Button>
      </div>
      <p
        aria-live="polite"
        className="min-h-[var(--hui-space-5)] text-[length:var(--hui-font-size-mini)] text-muted-foreground"
      >
        {submittedName
          ? `Created profile for ${submittedName}.`
          : "Submit with the button or by pressing Enter in the field."}
      </p>
    </Form>
  );
}

```

### Long labels [#long-labels]

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

```tsx
import { Button } from "@/components/honest-ui/ui/button";

export function ButtonLongText() {
  return (
    <div className="flex max-w-72 flex-col items-center gap-3">
      <Button className="max-w-full">
        <span className="truncate">
          Move all selected conversations to the archive folder
        </span>
      </Button>
      <Button variant="secondary">Archive selection</Button>
    </div>
  );
}

```

### Right-to-left languages [#right-to-left-languages]

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

```tsx
import { Plus, Search } from "honestui/icons";

import { Button } from "@/components/honest-ui/ui/button";

export function ButtonRtl() {
  return (
    <div
      dir="rtl"
      lang="ar"
      className="flex flex-wrap items-center justify-center gap-3"
    >
      <Button>
        <Plus aria-hidden="true" />
        عنصر جديد
      </Button>
      <Button variant="outline">
        <Search aria-hidden="true" />
        بحث
      </Button>
      <Button variant="ghost">إلغاء</Button>
    </div>
  );
}

```

## API reference [#api-reference]

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

| Prop         | Values                                                                                   | Default   |
| ------------ | ---------------------------------------------------------------------------------------- | --------- |
| `variant`    | `default`, `outline`, `secondary`, `destructive`, `destructive-outline`, `ghost`, `link` | `default` |
| `size`       | `xs`, `sm`, `default`, `lg`, `xl`, `icon`, `icon-sm`, `icon-lg`                          | `default` |
| `appearance` | `flat`, `glossy`, `glow`, `bevel`                                                        | `flat`    |
| `asChild`    | boolean                                                                                  | `false`   |

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.
