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';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.
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.
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 nameStyling 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.
| Field | What it paints |
|---|---|
| background | The resting surface |
| color | The label and the icon |
| ring | A hairline, as an inset shadow — a button is an object, so it has an edge |
| hover | The surface under the pointer. Left off, nothing moves |
| sweep | What fills a hold as it fills. Defaults to the background |
API reference
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged 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.
| Prop | Type | Default | |
|---|---|---|---|
| children | ReactNode | — | Leave it off for an icon-only button |
| variant | string | 'secondary' | primary, secondary, ghost, danger, or yours |
| variants | Record<string, ButtonVariant> | built-ins | Merged over them |
| size | number | 32 | Height in pixels; everything follows it |
| radius | number | size × 0.3 | |
| full | boolean | false | Fills the width it is given |
| icon | ReactNode | — | Before the label |
| trailing | ReactNode | — | After it |
| loading | boolean | false | Spins, and refuses presses |
| loadingLabel | string | 'Loading' | Announced while it does |
| copy | string | — | Put on the clipboard when pressed |
| copiedLabel | string | 'Copied' | Announced once it lands |
| copiedFor | number | 1400 | How long the check stays |
| hold | number | — | Milliseconds it must be held before it fires |
| href | string | — | Makes it a link |
| external | boolean | false | |
| theme | 'dark' | 'light' | 'dark' | |
| disabled | boolean | false |