Button
Atombeta@macaw-ds/ui · since 0.1.0Triggers an action. Not navigation; for links use asChild with an anchor.
Import
import { Button, buttonVariants } from '@macaw-ds/ui';Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "primary" | "secondary" | "ghost" | "destructive" | primary | Visual weight. primary is the one main action on a view; secondary for the rest; ghost for low-emphasis actions in toolbars and rows; destructive for irreversible actions, always behind confirmation. |
| size | "sm" | "md" | "lg" | md | 28, 32 or 40 px tall. md is the default for forms and toolbars; sm inside table rows; lg for page-level calls to action. Accessibility: All sizes meet the 24 px minimum target size (WCAG 2.2 2.5.8). |
| loading | boolean | false | Shows a spinner in place of the start icon, sets aria-busy and aria-disabled, and ignores clicks and form submits. The button stays focusable and the label stays visible. Accessibility: Keeps keyboard focus when an action starts: aria-disabled, not the disabled attribute. aria-busy is announced; the label is not replaced. |
| disabled | boolean | false | Not interactive. Prefer explaining why nearby. Accessibility: Disabled buttons are skipped in the tab order; if the reason matters, show it as text. |
| iconOnly | boolean | false | Square button whose only child is an Icon. Requires aria-label. Accessibility: TypeScript requires aria-label when iconOnly is true. |
| asChild | boolean | false | Render the child element (an anchor, a Next.js Link) with the button's styling. |
| iconStart | ReactNode | — | Decorative Icon before the label. |
| iconEnd | ReactNode | — | Decorative Icon after the label. |
| children | ReactNode | Pay invoice | The label. Verb first: "Pay invoice", "Export". |
Slots
Named parts you pass content into.
- children
- iconStart
- iconEnd
Tokens used
Every value this component renders comes from one of these tokens.
| Token | Light | Dark |
|---|---|---|
| button.bg.primary component · color | oklch(0.51 0.2 262) | oklch(0.51 0.2 262) |
| button.bg.primary-hover component · color | oklch(0.45 0.18 262) | oklch(0.45 0.18 262) |
| button.bg.primary-active component · color | oklch(0.38 0.15 262) | oklch(0.38 0.15 262) |
| button.fg.primary component · color | oklch(1 0 0) | oklch(1 0 0) |
| button.bg.secondary component · color | oklch(1 0 0) | oklch(0.15 0.012 250) |
| button.line.secondary component · color | oklch(0.66 0.01 250) | oklch(0.48 0.012 250) |
| button.bg.ghost-hover component · color | oklch(0.965 0.003 250) | oklch(0.21 0.012 250) |
| button.bg.destructive component · color | oklch(0.53 0.2 27) | oklch(0.53 0.2 27) |
| button.radius component · dimension | 4px | 4px |
| button.height.sm component · dimension | 28px | 28px |
| button.height.md component · dimension | 32px | 32px |
| button.height.lg component · dimension | 40px | 40px |
| color.action.disabled semantic · color | oklch(0.925 0.005 250) | oklch(0.29 0.012 250) |
| color.action.disabled-fg semantic · color | oklch(0.53 0.012 250) | oklch(0.66 0.01 250) |
| color.focus.ring semantic · color | oklch(0.51 0.2 262) | oklch(0.68 0.16 262) |
| shadow.xs primitive · shadow | 0px 1px 2px 0px oklch(0.15 0.012 250 / 0.06) | 0px 1px 2px 0px oklch(0.15 0.012 250 / 0.06) |
Eject
Ejecting copies the source into your repo and records it in .macaw/ejected.json, so the fork stays visible. The same file is served as a shadcn-compatible registry item.
npx @macaw-ds/cli add button --ejectnpx shadcn@latest add https://macaw-design-system.vercel.app/r/button.jsonBundle budget: 4,000 bytes gzipped, enforced by size-limit in CI.
Manifest
registry/manifests/button.json, the contract the playground, CLI, plugin and MCP server read.
{
"name": "Button",
"slug": "button",
"package": "@macaw-ds/ui",
"status": "beta",
"since": "0.1.0",
"level": "atom",
"category": "primitive",
"purpose": "Triggers an action. Not navigation; for links use asChild with an anchor.",
"description": "The action primitive. Four variants carry the weight of an action (one primary per view), three sizes match the control heights, and `loading` turns the button busy without losing its label. Icon-only buttons require an accessible name at the type level.",
"exports": [
"Button",
"buttonVariants"
],
"props": {
"variant": {
"type": "enum",
"values": [
"primary",
"secondary",
"ghost",
"destructive"
],
"default": "primary",
"required": false,
"description": "Visual weight. primary is the one main action on a view; secondary for the rest; ghost for low-emphasis actions in toolbars and rows; destructive for irreversible actions, always behind confirmation."
},
"size": {
"type": "enum",
"values": [
"sm",
"md",
"lg"
],
"default": "md",
"required": false,
"description": "28, 32 or 40 px tall. md is the default for forms and toolbars; sm inside table rows; lg for page-level calls to action.",
"a11y": "All sizes meet the 24 px minimum target size (WCAG 2.2 2.5.8)."
},
"loading": {
"type": "boolean",
"default": false,
"required": false,
"description": "Shows a spinner in place of the start icon, sets aria-busy and aria-disabled, and ignores clicks and form submits. The button stays focusable and the label stays visible.",
"a11y": "Keeps keyboard focus when an action starts: aria-disabled, not the disabled attribute. aria-busy is announced; the label is not replaced."
},
"disabled": {
"type": "boolean",
"default": false,
"required": false,
"description": "Not interactive. Prefer explaining why nearby.",
"a11y": "Disabled buttons are skipped in the tab order; if the reason matters, show it as text."
},
"iconOnly": {
"type": "boolean",
"default": false,
"required": false,
"description": "Square button whose only child is an Icon. Requires aria-label.",
"a11y": "TypeScript requires aria-label when iconOnly is true."
},
"asChild": {
"type": "boolean",
"default": false,
"required": false,
"description": "Render the child element (an anchor, a Next.js Link) with the button's styling.",
"control": "none"
},
"iconStart": {
"type": "node",
"required": false,
"description": "Decorative Icon before the label.",
"control": "none"
},
"iconEnd": {
"type": "node",
"required": false,
"description": "Decorative Icon after the label.",
"control": "none"
},
"children": {
"type": "node",
"default": "Pay invoice",
"required": false,
"description": "The label. Verb first: \"Pay invoice\", \"Export\"."
}
},
"slots": [
"children",
"iconStart",
"iconEnd"
],
"tokens": [
"button.bg.primary",
"button.bg.primary-hover",
"button.bg.primary-active",
"button.fg.primary",
"button.bg.secondary",
"button.line.secondary",
"button.bg.ghost-hover",
"button.bg.destructive",
"button.radius",
"button.height.sm",
"button.height.md",
"button.height.lg",
"color.action.disabled",
"color.action.disabled-fg",
"color.focus.ring",
"shadow.xs"
],
"a11y": {
"role": "button (or the child's role with asChild)",
"focusable": true,
"keyboard": [
"Tab: focus",
"Enter, Space: activate",
"Disabled buttons leave the tab order; loading buttons stay focusable and ignore activation"
],
"name": "From the visible label (children). Icon-only buttons take it from aria-label, which TypeScript requires.",
"notes": [
"Focus ring is 2 px outline with 2 px offset at 3:1 or better against every surface.",
"loading keeps the label visible and sets aria-busy; it never swaps the label for a spinner alone.",
"Use a real button for actions and an anchor (asChild) for navigation so assistive technology announces the right thing.",
"A disabled asChild link gets aria-disabled and its clicks are blocked, so it never navigates."
]
},
"motion": {
"description": "Background and border colours ease on hover and press; the loading spinner rotates.",
"tokens": [
"duration.fast",
"ease.standard",
"animation.spin",
"duration.spin"
],
"whenProp": "loading"
},
"budget": {
"gzip": 4000
},
"rules": [
"One primary button per view. Everything else is secondary or ghost.",
"Label is a verb phrase: \"Pay invoice\", not \"OK\" or \"Submit\".",
"Icon-only buttons set iconOnly and aria-label.",
"Destructive actions sit behind a confirmation step.",
"Navigation uses asChild with an anchor; never a button with an onClick that routes."
],
"dos": [
"Group a primary and a secondary button with gap-2, primary last in LTR layouts.",
"Use size=\"sm\" ghost buttons for row actions in tables."
],
"donts": [
"Do not pass className or style; use a variant or propose one in DECISIONS.md.",
"Do not place two primary buttons side by side.",
"Do not use loading for operations under 300 ms; it flickers."
],
"examples": [
{
"name": "Default",
"props": {
"children": "Pay invoice"
}
},
{
"name": "Secondary",
"props": {
"variant": "secondary",
"children": "Download PDF"
}
},
{
"name": "Ghost",
"props": {
"variant": "ghost",
"children": "Cancel"
}
},
{
"name": "Destructive",
"props": {
"variant": "destructive",
"children": "Void invoice"
}
},
{
"name": "Loading",
"props": {
"loading": true,
"children": "Processing"
}
},
{
"name": "Small",
"props": {
"size": "sm",
"variant": "secondary",
"children": "Remind"
}
},
{
"name": "Icon only",
"description": "Square, with a required accessible name.",
"props": {
"iconOnly": true,
"variant": "ghost",
"aria-label": "More actions"
}
}
],
"registryDependencies": [],
"dependencies": [
"radix-ui",
"class-variance-authority",
"lucide-react"
],
"related": [
"icon",
"card"
]
}