Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Button

Trigger an action — six variants × five tones, four sizes, with loading and Base UI Button semantics.

Status
stable
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/button

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

(no solid destructive)

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

PropTypeDefaultDescription
classNamestring | ((state: ButtonState) => string | undefined)Classes or a Base UI state resolver merged with the button variants.
data-loadingstringLoading-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-slotstring'button'Slot marker for wrapper components that compose Button through Base UI render and need their own generated registry slot.
loadingbooleanfalseShows 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.
sizeButtonSize'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-visible every variant keeps the design system's global 2px focus ring (outline-ring) — Button sets no outline: none of its own, so the single global focus rule applies. The outline variant additionally re-colors its border with the ring token (focus-visible:border-ring/(--alpha-tint-border)).
  • loading sets aria-busy="true", prevents interaction, and remains focusable through Base UI focusableWhenDisabled.
  • disabled renders aria-disabled rather than the native attribute, so the control keeps its pointer events and stays focusable — wrap it in a Tooltip to say why it is unavailable. Base UI still suppresses activation.
  • render is for action elements. Do not render links through Button; style anchors with buttonVariants({ variant, tone, size }) when navigation should look like a button.
  • Icon-only buttons must use IconButton, which makes the missing aria-label a type error.

The states the verification lanes exercise for this item (generated from component-contracts.json):

ContractStates tested
Behaviourdefault, active, disabled, error, invalid, loading, success
Accessibilitybusy, disabled, focus-visible, invalid, labeled
Visualdefault, hover, focus, active, disabled, invalid, loading, error, success, dark

Do / Don't

Do
Signal intent with tone — soft destructive for delete, soft success for confirm.
Don't
Use Button for navigation to a URL — use a link styled with buttonVariants.

On this page