Skip to content

Button

Triggers an action in place: submit, save, open a dialog.

  • Navigating to another page: use Link.
  • An icon with no visible text: use IconButton.

Install

Terminal
pnpm dlx shadcn@latest add @acme/button

Or import from the package: import { Button } from '@acme/react'

Examples

Variants

button-variants.tsx
import { Button } from '@/components/ui/button'

export default function ButtonVariants() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button>Save changes</Button>
      <Button variant="secondary">Duplicate</Button>
      <Button variant="outline">Export</Button>
      <Button variant="ghost">Cancel</Button>
      <Button variant="destructive">Delete project</Button>
    </div>
  )
}

Sizes

button-sizes.tsx
import { Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'

export default function ButtonSizes() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
      <Button>
        <Plus aria-hidden="true" />
        New invoice
      </Button>
    </div>
  )
}

Loading

button-loading.tsx
'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'

export default function ButtonLoading() {
  const [saving, setSaving] = useState(false)
  return (
    <Button
      loading={saving}
      onClick={() => {
        setSaving(true)
        setTimeout(() => setSaving(false), 2000)
      }}
    >
      {saving ? 'Saving…' : 'Save changes'}
    </Button>
  )
}

Native

The same component for iOS (SwiftUI, AcmeUI) and Android (Jetpack Compose, com.acme.ui), built on each platform's own controls with the same tokens.

AcmeButton

Swift
AcmeButton("Save changes") { save() }
AcmeButton("Delete", variant: .destructive, size: .sm) { delete() }
AcmeButton("Saving", isLoading: true) { }
  • A system Button with AcmeButtonStyle; hit area is at least 44×44pt around the 28/36/44pt visual height.
  • isLoading keeps the button in the VoiceOver order with the value "Loading" and blocks repeat taps.
  • Use .buttonStyle(AcmeButtonStyle(variant:)) to style any Button.
Button on iOS, light mode
iOS, light mode
Button on iOS, dark mode
iOS, dark mode

Props

Button props
PropTypeDefaultDescription
variant'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive''primary'Visual emphasis. One primary per view.
size'sm' | 'md' | 'lg''md'Height 28, 36 or 44px. Hit area is at least 44px on touch.
loadingbooleanfalseDisables the button, keeps it focusable, sets aria-busy and shows a spinner.
type'button' | 'submit' | 'reset''button'Defaults to 'button'. Set 'submit' explicitly in forms.
renderReactElement | function—Replace the rendered element (must stay a button-like element; for links use Link).

Accessibility

Keyboard
KeyAction
EnterActivates the button
SpaceActivates the button
Role
button
You provide
  • Visible text, or use IconButton with a label.
Built in
  • Disabled buttons are exempt from contrast but still read as disabled (50% opacity, data-disabled).
  • Loading keeps focus on the button so keyboard users stay in place.
  • buttonVariants lives in button-variants.ts (server-safe) so server components can style links as buttons.
WCAG 2.2 criteria
1.4.3, 1.4.11, 2.1.1, 2.4.7, 2.5.8, 4.1.2

Don’t

  • More than one primary button per view.
  • Use a Button for navigation.
  • Change the label while loading without aria-labelledby (keep the label stable).

Tokens

  • color.primary
  • color.primary-foreground
  • color.primary-hover
  • color.secondary
  • color.accent
  • color.input
  • color.destructive
  • color.destructive-hover
  • color.ring
  • radius.md
  • size.control.*