Action

Button

A button, and the four things a button turns into. One number drives the height, the padding, the radius and the type — so a button is a size, not a size name you have to remember the meaning of.

Usage

import { Button } from '@carabine/ui/button';
<Button variant="primary">Deploy</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="ghost">Skip</Button>
<Button variant="danger">Delete</Button>

The variants are semantic, not visual. primary is the one thing on the screen worth doing, and there should be one.

Anatomy

<button>          // a real button — type="button", never submit by accident
  {icon}          //   before the label
  {children}      //   the label, and the accessible name
  {trailing}      //   after it
</button>

Everything a <button> takes passes through — type, form, name, value, every aria-*. Give it an href and it renders an anchor instead, wearing the same skin.

Examples

The four states

loading spins and refuses presses. copy puts a string on the clipboard and morphs the icon to a check on its own — there is no state to hold, which is the whole point of the prop. hold fills the button as it is held and fires only at the end.

<Button variant="primary" loading={busy} icon={<Rocket />}>Deploy</Button>
<Button variant="secondary" copy="pnpm add @carabine/ui">pnpm add @carabine/ui</Button>
<Button variant="danger" hold={1200} icon={<Trash2 />}>Hold to delete</Button>

Your own variant

The key set is open. variants is merged over the four built-ins, so the same prop restyles one of them or invents a fifth — and the colours are plain CSS strings, never classes, because a class buried in a component is invisible from the outside.

<Button
  variant="brand"
  variants={{
    brand: {
      dark: { background: '#4a86ff', color: '#fff', hover: '#2465ff', sweep: '#91bdff' },
      light: { background: '#2465ff', color: '#fff', hover: '#1552ed', sweep: '#91bdff' },
    },
  }}
>
  A fifth variant
</Button>

Customization

<Button size={28}>Small</Button>
<Button size={44} radius={12}>Large, squarer</Button>
<Button full>Fills the width it is given</Button>
<Button icon={<Copy />} aria-label="Copy" />   // icon-only still has a name

Styling reference

Tailwind utilities plus button.css, which carries the hold sweep's keyframe — a fill driven by a custom property is not something a utility can say. Press feedback is active:scale-[0.97] in CSS rather than in Motion, so it cannot wait behind whatever else has the main thread. It is the movement a dropped frame ruins.

FieldWhat it paints
backgroundThe resting surface
colorThe label and the icon
ringA hairline, as an inset shadow — a button is an object, so it has an edge
hoverThe surface under the pointer. Left off, nothing moves
sweepWhat fills a hold as it fills. Defaults to the background

API reference

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
childrenReactNodeLeave it off for an icon-only button
variantstring'secondary'primary, secondary, ghost, danger, or yours
variantsRecord<string, ButtonVariant>built-insMerged over them
sizenumber32Height in pixels; everything follows it
radiusnumbersize × 0.3
fullbooleanfalseFills the width it is given
iconReactNodeBefore the label
trailingReactNodeAfter it
loadingbooleanfalseSpins, and refuses presses
loadingLabelstring'Loading'Announced while it does
copystringPut on the clipboard when pressed
copiedLabelstring'Copied'Announced once it lands
copiedFornumber1400How long the check stays
holdnumberMilliseconds it must be held before it fires
hrefstringMakes it a link
externalbooleanfalse
theme'dark' | 'light''dark'
disabledbooleanfalse