opsinjs
ComponentsActions and forms

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

Theme
Density
Text
Status
button · base base · style base-lyraOpen under the product theme

Installation

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 link instead.
  • The control turns something on or off immediately, with no confirmation step.Use switch instead.
  • The control chooses one of a few options rather than performing an action.Use segmented-control instead.
  • The control is icon-only, in a toolbar or a header.Use icon-button instead.
  • The action is a clinical instruction with an author and a time behind it.Use care-card instead.

Anatomy

  1. ButtonAlways a real button element. Carries the variant's fill, ink and boundary, the size step and the focus ring.Controlled by variant
  2. Button.TargetAn aria-hidden hit area floored at --opsin-target-minimum, for a project without the product stylesheet.
  3. Button.IconAn optional decorative glyph, leading or trailing, sized in em so it grows with the label.Controlled by icon
  4. Button.BusyThe busy indicator, in the icon's seat: a spinner, or a still hourglass under reduced motion.Controlled by busy
  5. Button.LabelThe text and the accessible name. No prop removes it, and there is no icon-only mode.Controlled by children

Examples

One primary per surface

One filled button answers what should I do here?. Everything else is secondary or quiet.

Theme
Density
Text
Status
button-one-primary-per-surface · base base · style base-lyraOpen under the product theme

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.

Theme
Density
Text
Status
button-busy-at-the-foot-of-a-form · base base · style base-lyraOpen under the product theme

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.

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.

KeyActionNotes
TabMoves focus to the buttonOne tab stop for the whole control. A busy button keeps its stop.
Shift+TabMoves focus back outNatural DOM order. A tabIndex above zero raises a development warning.
EnterActivates the buttonPlatform behaviour on a real button element. Blocked while busy.
SpaceActivates the buttonConsumed by the button, so the page does not scroll. Blocked while busy.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

Data attributes

AttributeConditionValue
data-slotOn the root and every inner part, alwaysbutton, button-target, button-icon, button-busy, button-busy-label, button-label
data-disabledOn the root while busy or while the caller's disabled prop is truePresent 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.

  • 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.

On this page