Button
Trigger an action — six variants × five tones, four sizes, with loading and Base UI Button semantics.
- Status
- Since
0.1.0- Accessibility pattern
- native button
Last updated
Install
Add Button from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/buttonThe same command installs the registry items it composes: @vegastack/spinner.
Usage
import { Button } from "@/components/ui/button";
<Button>Save changes</Button>;Appearance is two axes. variant is the shape — solid, soft, outline, ghost, link, plus
the marketing cta. tone is the hue — neutral (the default), destructive, success,
warning, info. Every recipe is written once and reads the tone through CSS custom properties,
so hover and pressed behave identically in all thirty cells.
<Button variant="soft" tone="destructive">
Delete
</Button>To render a Button as a link, compose the anchor through render and pass nativeButton={false}
— Base UI expects a native <button> by default and warns when render swaps in anything else:
import Link from "next/link";
<Button nativeButton={false} render={<Link href="/docs" />}>
Explore the docs
</Button>;Examples
Variants
The five product shapes at the default neutral tone. link is styled as a text link.
Tones
The same shape carrying each of the five hues. Status colour lives on tone, never in the variant
name.
Variant × tone matrix
The full grid, rows by variant and columns by tone. solid × destructive is deliberately absent:
a destructive action is a soft (or outline / ghost / link) button, and passing that pair is a
TypeScript error.
Marketing CTA
cta is the ONE sanctioned use of the --brand phosphor accent as a button (audit
17-brand-direction §Color & surface) — an accent-outline treatment, sharp corners
(rounded-(--radius-sharp)), and a mono-uppercase label (the brand voice layer). Reserve it for
marketing surfaces, typically inside a MarketingSurface.
Compose a trailing icon (e.g. lucide-react's ChevronRight) as a child — the variant is
style-only and never bakes one in. cta is brand-locked and takes no tone.
<Button variant="cta">
Get started
<ChevronRight />
</Button>Sizes
xs, sm, md, lg — the same vocabulary the --size-* tokens use. Icon-only actions are
IconButton, which is the only icon-only path: Button has no
icon size tier.
States
Icons compose as children; loading stacks a spinner over the label so the button's width does not
move; disabled is aria-disabled and stays hoverable so a Tooltip can explain why.
Playground
Pick a variant, a tone, and a size, flip disabled / loading, then copy the generated JSX.
<Button>Save changes</Button>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: ButtonState) => string | undefined) | — | Classes or a Base UI state resolver merged with the button variants. |
data-loading | string | — | Loading-state marker for wrapper components that reflect a host-owned pending
state onto a composed Button without its loading visuals (e.g. SplitButton's
chevron half). The Button's own loading prop always wins when set. |
data-slot | string | 'button' | Slot marker for wrapper components that compose Button through Base UI
render and need their own generated registry slot. |
loading | boolean | false | Shows a spinner over the label, disables interaction, and sets aria-busy. The
label keeps its box at opacity: 0, so the button's width does not move across
the flip and its accessible name survives. |
size | ButtonSize | 'md' | Control height, from the one xs · sm · md · lg vocabulary the --size-* tokens carry. |
Accessibility
- Renders a native
<button type="button">by default; keyboard Enter / Space activate it. - On
:focus-visibleevery variant keeps the design system's global 2px focus ring (outline-ring) —Buttonsets nooutline: noneof its own, so the single global focus rule applies. Theoutlinevariant additionally re-colors its border with theringtoken (focus-visible:border-ring/(--alpha-tint-border)). loadingsetsaria-busy="true", prevents interaction, and remains focusable through Base UIfocusableWhenDisabled.disabledrendersaria-disabledrather than the native attribute, so the control keeps its pointer events and stays focusable — wrap it in aTooltipto say why it is unavailable. Base UI still suppresses activation.renderis for action elements. Do not render links throughButton; style anchors withbuttonVariants({ variant, tone, size })when navigation should look like a button.- Icon-only buttons must use
IconButton, which makes the missingaria-labela type error.
The states the verification lanes exercise for this item (generated from component-contracts.json):
| Contract | States tested |
|---|---|
| Behaviour | default, active, disabled, error, invalid, loading, success |
| Accessibility | busy, disabled, focus-visible, invalid, labeled |
| Visual | default, hover, focus, active, disabled, invalid, loading, error, success, dark |