Button
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.
Preview
button · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/button
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Button } from "@/components/ui/button"<Button variant="primary" onClick={save}>
Save reading
</Button>When to use it
Use it when
- An action that changes something: saving a reading, submitting a form, confirming a decision.
- The one primary action on a surface, with everything else secondary or quiet.
Do not use it when
- The control takes the reader somewhere. A new URL is a link, however it is styled.Use
linkinstead. - The control turns something on or off immediately, with no confirmation step.Use
switchinstead. - The control chooses one of a few options rather than performing an action.Use
segmented-controlinstead. - The control is icon-only, in a toolbar or a header.Use
icon-buttoninstead. - The action is a clinical instruction with an author and a time behind it.Use
care-cardinstead.
Anatomy
ButtonAlways a real button element. Carries the variant's fill, ink and boundary, the size step and the focus ring.Controlled byvariantButton.TargetAn aria-hidden hit area floored at --opsin-target-minimum, for a project without the product stylesheet.Button.IconAn optional decorative glyph, leading or trailing, sized in em so it grows with the label.Controlled byiconButton.BusyThe busy indicator, in the icon's seat: a spinner, or a still hourglass under reduced motion.Controlled bybusyButton.LabelThe text and the accessible name. No prop removes it, and there is no icon-only mode.Controlled bychildren
Examples
One primary per surface
One filled button answers what should I do here?. Everything else is secondary or quiet.
button-one-primary-per-surface · base base · style base-lyraOpen under the product themeBusy, 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.
button-busy-at-the-foot-of-a-form · base base · style base-lyraOpen under the product themeContent 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.
Do
"Save reading" names the outcome and survives being read out of context.
Don’t
"Submit" on a screen with three of them tells a screen reader user nothing.
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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus to the button | One tab stop for the whole control. A busy button keeps its stop. |
| Shift+Tab | Moves focus back out | Natural DOM order. A tabIndex above zero raises a development warning. |
| Enter | Activates the button | Platform behaviour on a real button element. Blocked while busy. |
| Space | Activates the button | Consumed by the button, so the page does not scroll. Blocked while busy. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
Data attributes
| Attribute | Condition | Value |
|---|---|---|
| data-slot | On the root and every inner part, always | button, button-target, button-icon, button-busy, button-busy-label, button-label |
| data-disabled | On the root while busy or while the caller's disabled prop is true | Present with no value; absent otherwise |
API reference
Prop
Type
Generated from ButtonProps in registry/bases/base/button.tsx.
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
- Link is for navigation. A button acts and a link goes somewhere, which is why this component cannot render an anchor.
- IconButton is icon-only.
- Switch covers an immediate on or off with no confirmation step.
- SegmentedControl covers choosing among options rather than acting.
- CareCard is where a clinical action lives, with its author and timing.