Skip to main content

Button

Atombeta@macaw-ds/ui · since 0.1.0

Triggers an action. Not navigation; for links use asChild with an anchor.

Import

import { Button, buttonVariants } from '@macaw-ds/ui';

Props

PropTypeDefaultDescription
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).

loadingbooleanfalse

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.

disabledbooleanfalse

Not interactive. Prefer explaining why nearby.

Accessibility: Disabled buttons are skipped in the tab order; if the reason matters, show it as text.

iconOnlybooleanfalse

Square button whose only child is an Icon. Requires aria-label.

Accessibility: TypeScript requires aria-label when iconOnly is true.

asChildbooleanfalse

Render the child element (an anchor, a Next.js Link) with the button's styling.

iconStartReactNode—

Decorative Icon before the label.

iconEndReactNode—

Decorative Icon after the label.

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

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

With the MACAW CLI
npx @macaw-ds/cli add button --eject
With shadcn
npx shadcn@latest add https://macaw-design-system.vercel.app/r/button.json

Bundle 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"
  ]
}