Button

Triggers an action or navigates to a new page. Seven variants, seven sizes, and built-in loading, icon, polymorphic, and invalid states.

Anatomy

leftIconchildrenrightIcon
The button accepts an optional left and right icon slot, a label, and a loading state. Each icon slot is wrapped in a shrink-0 span and rendered as-is — size the icon yourself; the demos use 14px next to a 14px label.

Variants

Seven variants cover every use case from primary CTAs to in-line link actions. primary is a kept alias of default — the two render the same classes, so pick one and stay consistent.

The resting fill of default/primary is the --primary token, which has its own dark value; the hover fill is the raw --gold-400, identical in both themes. So rebranding by overriding --primary alone moves the resting fill but leaves the hover Comitor gold — override the --gold-* scale as well.

Sizes

Use sm for compact UI, md for standard forms, and lg for marketing CTAs. Each has a square counterpart — icon-sm (32px), icon (36px), and icon-lg (44px) — so an icon-only button lines up with the labelled buttons beside it. The unnamed default size is the same 36px as md.

Loading state

Pass loading to put a spinning Loader2 in the leftIcon slot. The label stays visible, rightIcon is hidden so the button does not jump width, and the element is disabled for you — you never need to pass disabled alongside it.

With icons

Use leftIcon or rightIcon for labeled buttons, and an icon size with the icon as the child for icon-only buttons.

Render as another element

asChild merges the button classes onto its single child instead of rendering a <button> — the way to make a real link that looks like a button (wrap a Next <Link> the same way).

Trap: Radix Slot accepts exactly one child, so the component skips its icon and spinner wrappers entirely when asChild is set. leftIcon, rightIcon and loading are silently ignored — put the icon inside the child element instead. disabled is dropped too: it is pulled off the props before they reach the child, so set it on the child yourself.

The package also exports the raw buttonVariants recipe. Reach for it only when you cannot render a Button at all — asChild is the better answer almost every time.

tsx
import { buttonVariants, cn } from '@comitor/ui'

// For the rare case where you cannot render a <Button> at all — a render prop
// from a third-party library, or an anchor produced by a markdown renderer.
export function DocsLink() {
  return (
    <a href="/docs" className={cn(buttonVariants({ variant: 'outline', size: 'sm' }))}>
      Documentation
    </a>
  )
}

Invalid state

Every variant answers aria-invalid with a --destructive-ink border and a matching focus outline. This matters for controls built on top of Button Combobox and MultiCombobox render their trigger as a button — so the field itself turns red, not just the error text underneath it.

Disabled

All variants respect the native disabled attribute, reducing opacity to 50% and removing pointer events.

Props

PropTypeDefaultDescription
variant'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link''default'Visual style of the button. primary is an alias of default and renders identically.
size'default' | 'md' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg''default'Controls height, padding, and font size. default and md are the same 36px height; the icon-* sizes are square.
asChildbooleanfalseRenders the single child element with the button classes merged onto it (Radix Slot) instead of a <button>.
loadingbooleanfalseSwaps the leftIcon slot for a spinning Loader2, hides rightIcon, and disables the button. Ignored when asChild is set.
leftIconReact.ReactNodeNode rendered before the label in a shrink-0 span. Ignored when asChild is set.
rightIconReact.ReactNodeNode rendered after the label in a shrink-0 span. Ignored when asChild is set or while loading.
disabledbooleanfalseDisables the button and drops it to 50% opacity. Forced on while loading. Not forwarded when asChild is set — disable the child element instead.
classNamestringAdditional Tailwind classes, merged into the variant classes by cn().
...restReact.ComponentProps<'button'>All native button props are forwarded.

Usage rules

Do

  • Use primary for the single most important action on a page.
  • Use ghost for tertiary actions that should not compete visually.
  • Always include an aria-label on icon-only buttons.
  • Use destructive only for irreversible actions like delete.
  • Use asChild when the control navigates — a link should be a link.

Don't

  • Do not place two primary buttons side-by-side — only one primary per view.
  • Do not use link variant as a navigation element inside prose.
  • Do not mix icon-only and labeled buttons in the same button group.
  • Do not pass leftIcon together with asChild — it is dropped.

Accessibility

  • The component renders a native <button> element — keyboard focus, Enter, and Space activation are handled by the browser for free.
  • All variants draw a focus-visible outline — 2px of --ring at 2px offset. Because the outline is painted outside the border box, a neighbour would cover it inside a joined ButtonGroup; the group is what prevents that, by lifting the focused member with focus-visible:relative and focus-visible:z-10.
  • Disabled state uses disabled not aria-disabled, so the element is correctly removed from the accessibility tree.
  • The loading spinner is aria-hidden and announces nothing on its own — for a critical async action, add aria-busy or a visually-hidden live region alongside it.
  • aria-invalid is reflected visually as well as semantically, so a screen-reader user and a sighted user learn about the error at the same moment.

Three contrast debts are documented in the component source and deliberately kept, because the approved Comitor design is the source of truth for these variants: white on --destructive measures 4.38:1 (0.12 short of AA), the link variant measures 3.15:1 on the page background, and the outline border measures 1.16:1. The high-contrast palette (data-contrast="high") repays the link debt by darkening --link-ink; never rely on the outline border alone to carry meaning.