Button Atom

Four variants, six tones, three sizes — and a press animation that comes from the motion tokens, so it feels the same as every other press in the product.

Playground

variant

tone

size

tsx
<Button variant="solid" tone="brand" size="md">  Save changes</Button>

Variants

tsx · motion on
<Button variant="solid">Save</Button><Button variant="soft">Save</Button><Button variant="outline">Save</Button><Button variant="ghost">Save</Button>

Tone

tsx · motion on
<Button tone="danger">Delete</Button>

Loading

loading disables the button, sets aria-busy, and swaps the leading icon for a spinner. The label stays, so the button does not change width and the pointer does not land on something else.

tsx · motion on
<Button loading>Saving</Button>

Do

Keep the label visible while loading, so the button holds its size and its meaning.

Don't

Replace the label with a bare spinner — the row reflows and the action becomes anonymous.

A link that looks like one

Button renders a <button> and only a <button>. Where the thing goes somewhere rather than does something, reach for LinkButton — same classes, same tones and sizes, but an anchor.

tsx
import { LinkButton } from '@ceebee/ui';
<LinkButton href="/places/12" variant="ghost" tone="neutral" size="sm">History</LinkButton>
// A router's own Link has to stay itself, or navigation stops being client-side:<LinkButton render={<Link href="/places/12" />} variant="ghost" iconStart={<History />} />

They are two components because they are two contracts (ADR 0014). A link can be opened in a new tab, copied, and reached from a screen reader's list of links; a button can be disabled and can submit a form. The look is shared literally — the same cb-button classes — so it cannot drift.

LinkButton is server-safe: an anchor needs no hook, no handler and no motion. It does not animate on press, which is honest — a link does not depress, it goes somewhere.

Props

PropTypeDefaultNotes
variant'solid' | 'soft' | 'outline' | 'ghost''solid'Visual weight. One solid button per view is usually enough.
toneTone'brand'Semantic colour.
size'sm' | 'md' | 'lg''md'Control height token.
loadingbooleanfalseDisables, sets aria-busy, shows a spinner in place of iconStart.
iconStart / iconEndReactNodeAny icon element — the library never picks the icon set for you.
motionbooleantrueOpts this button out of press feedback without touching MotionProvider.

Motion redefines the drag and animation DOM events, so those handlers are not accepted here. A button that needs HTML5 drag is a different component.