Components
Button
Triggers an action or navigates to a new page. Seven variants, seven sizes, and built-in loading, icon, polymorphic, and invalid states.
Anatomy
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
asChild | boolean | false | Renders the single child element with the button classes merged onto it (Radix Slot) instead of a <button>. |
loading | boolean | false | Swaps the leftIcon slot for a spinning Loader2, hides rightIcon, and disables the button. Ignored when asChild is set. |
leftIcon | React.ReactNode | — | Node rendered before the label in a shrink-0 span. Ignored when asChild is set. |
rightIcon | React.ReactNode | — | Node rendered after the label in a shrink-0 span. Ignored when asChild is set or while loading. |
disabled | boolean | false | Disables the button and drops it to 50% opacity. Forced on while loading. Not forwarded when asChild is set — disable the child element instead. |
className | string | — | Additional Tailwind classes, merged into the variant classes by cn(). |
...rest | React.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-labelon 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
leftIcontogether withasChild— 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-visibleoutline — 2px of--ringat 2px offset. Because the outline is painted outside the border box, a neighbour would cover it inside a joinedButtonGroup; the group is what prevents that, by lifting the focused member withfocus-visible:relativeandfocus-visible:z-10. - Disabled state uses
disablednotaria-disabled, so the element is correctly removed from the accessibility tree. - The loading spinner is
aria-hiddenand announces nothing on its own — for a critical async action, addaria-busyor a visually-hidden live region alongside it. aria-invalidis 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.