---
title: "Button"
description: "A control you press to make something happen. It is the least clinical component here, and its page sets the pattern every other component page follows."
url: "https://opsinjs.pensievelabs.org/components/button"
source: "https://opsinjs.pensievelabs.org/components/button.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["cta", "action", "submit", "primary button"]
usedIn: ["consent-and-permissions", "consent-flow", "daily-log-entry", "daily-log-screen", "diabetes-medicines-app", "empty-and-first-use", "forms/error-summaries", "forms/question-pages", "health-metric-card", "onboarding-and-first-run", "onboarding-screen"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="button"
  status="shipped"
  questions="[
  &#x22;Whether an ink outline is warning enough for the destructive variant, or whether a destructive action belongs behind a confirmation Dialog instead.&#x22;,
  &#x22;A busy button given no busyLabel leaves a reduced-motion reader a still glyph and no word for what is happening.&#x22;,
  &#x22;Base UI defaults type to button, so a form's submit control has to say type=\&#x22;submit\&#x22;; whether to restore the platform default is open.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="button" />

## Installation [#installation]

<ComponentInstall name="button" unbuilt="false" importPath="@/components/ui/button" dependencies="[&#x22;@base-ui/react&#x22;, &#x22;lucide-react&#x22;]" />

## Usage [#usage]

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

```tsx
<Button variant="primary" onClick={save}>
  Save reading
</Button>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;An action that changes something: saving a reading, submitting a form, confirming a decision.&#x22;,
  &#x22;The one primary action on a surface, with everything else secondary or quiet.&#x22;,
]"
  avoid="[
  { case: &#x22;The control takes the reader somewhere. A new URL is a link, however it is styled.&#x22;, instead: &#x22;link&#x22; },
  { case: &#x22;The control turns something on or off immediately, with no confirmation step.&#x22;, instead: &#x22;switch&#x22; },
  { case: &#x22;The control chooses one of a few options rather than performing an action.&#x22;, instead: &#x22;segmented-control&#x22; },
  { case: &#x22;The control is icon-only, in a toolbar or a header.&#x22;, instead: &#x22;icon-button&#x22; },
  { case: &#x22;The action is a clinical instruction with an author and a time behind it.&#x22;, instead: &#x22;care-card&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="button"
  parts="[
  {
    name: &#x22;Button&#x22;,
    describes: &#x22;Always a real button element. Carries the variant's fill, ink and boundary, the size step and the focus ring.&#x22;,
    prop: &#x22;variant&#x22;,
  },
  {
    name: &#x22;Button.Target&#x22;,
    describes: &#x22;An aria-hidden hit area floored at --opsin-target-minimum, for a project without the product stylesheet.&#x22;,
  },
  {
    name: &#x22;Button.Icon&#x22;,
    describes: &#x22;An optional decorative glyph, leading or trailing, sized in em so it grows with the label.&#x22;,
    prop: &#x22;icon&#x22;,
  },
  {
    name: &#x22;Button.Busy&#x22;,
    describes: &#x22;The busy indicator, in the icon's seat: a spinner, or a still hourglass under reduced motion.&#x22;,
    prop: &#x22;busy&#x22;,
  },
  {
    name: &#x22;Button.Label&#x22;,
    describes: &#x22;The text and the accessible name. No prop removes it, and there is no icon-only mode.&#x22;,
    prop: &#x22;children&#x22;,
  },
]"
/>

## Examples [#examples]

### One primary per surface [#one-primary-per-surface]

One filled button answers &#x2A;what should I do here?*. Everything else is secondary
or quiet.

<ComponentPreview name="button-one-primary-per-surface" kind="example" align="start" />

### Busy, at the foot of a form [#busy-at-the-foot-of-a-form]

`busy` keeps the label, blocks activation and shows the indicator in the icon's
seat. `busyLabel="Saving"` gives the state a word, and `fullWidth` stops the control
growing when it appears.

<ComponentPreview name="button-busy-at-the-foot-of-a-form" kind="example" align="start" />

## Content guidelines [#content-guidelines]

A label is a verb that names its own outcome, in sentence case, with no full stop:
*Save reading*, *Delete this reading*. Never *OK*, *Submit*, *Yes* or *Click here*,
each of which sends the reader back up the page to reconstruct the question.

<DoDont>
  <DoDont.Do>
    **"Save reading"** names the outcome and survives being read out of context.
  </DoDont.Do>

  <DoDont.Dont>
    **"Submit"** on a screen with three of them tells a screen reader user nothing.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is
author-run, not an independent review, and clinical review is still pending. The
rendered pass on the isolated view was clean, with no axe, target-size, focus-visible
or reflow failure. The source met every check, so the audit changed nothing.

It confirmed the load-bearing behaviour: a real `button` with the platform role and
keys, the visible label as the accessible name with the icon `aria-hidden`, `busy`
handled through `aria-busy` and `aria-disabled` so the control keeps its tab stop,
and the outline focus ring that never shifts layout.

**Residual items to know.** No live region is mounted, by design: `busy` reaches the
tree on the focused control as `aria-busy` plus `aria-disabled`, and `busyLabel`
carries a word there, but with no `busyLabel` it is not otherwise announced. Because
the ring is an outline with offset, an ancestor set to clip can hide it. `Button.Target` raises the hit area to the 44px floor under the SC 2.5.8
inline exception, so a project without the product stylesheet owes
`--opsin-target-separation`. In dark mode the quiet and primary labels clear the
WCAG 4.5:1 floor but fall under the stricter APCA figure, which sits outside WCAG
2.2 AA and belongs to the theme token layer.

<KeyboardTable
  name="button"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus to the button&#x22;,
    notes: &#x22;One tab stop for the whole control. A busy button keeps its stop.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out&#x22;,
    notes: &#x22;Natural DOM order. A tabIndex above zero raises a development warning.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the button&#x22;,
    notes: &#x22;Platform behaviour on a real button element. Blocked while busy.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates the button&#x22;,
    notes: &#x22;Consumed by the button, so the page does not scroll. Blocked while busy.&#x22;,
  },
]"
/>

<ContrastReport component="button" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="button"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every inner part, always&#x22;,
    value: &#x22;button, button-target, button-icon, button-busy, button-busy-label, button-label&#x22;,
  },
  {
    attribute: &#x22;data-disabled&#x22;,
    condition: &#x22;On the root while busy or while the caller's disabled prop is true&#x22;,
    value: &#x22;Present with no value; absent otherwise&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="ButtonProps" />

`type` defaults to `button`, not `submit`, so a form's submit control has to say
`type="submit"`. There is no `disabled` convenience prop: the native attribute is
the whole of it, and passing `busy` and `disabled` together warns in development.
`className` and `style` merge caller last and must never resolve a `--opsin-status-*`
or `--opsin-category-*` role, because a Button carries no clinical axis.

## Related [#related]

* [Link](./link.mdx) is for navigation. A button acts and a link goes somewhere,
  which is why this component cannot render an anchor.
* [IconButton](./icon-button.mdx) is icon-only.
* [Switch](./switch.mdx) covers an immediate on or off with no confirmation step.
* [SegmentedControl](./segmented-control.mdx) covers choosing among options rather than acting.
* [CareCard](./care-card.mdx) is where a clinical action lives, with its author and timing.
