콘텐츠로 이동

Button — spec

Status: example artifact for component-spec-writer skill Generated by: skills/component-spec-writer skill, citing Ant Design, MUI, and shadcn-ui

Purpose

A button performs an action when activated. The most-used component in any product UI. It must be unmistakable as interactive, support all input modalities, and clearly express priority.

Anatomy

Button
├── [iconStart]?         // optional leading icon
├── label                // children — text or ReactNode
├── [iconEnd]?           // optional trailing icon
└── [loadingIndicator]?  // shown when loading
Part Purpose Required Default if omitted
iconStart Visual cue before the label no none
label Action text yes (children) renders empty button — usually a bug
iconEnd Visual cue after the label (e.g., chevron, arrow) no none
loadingIndicator Spinner shown when loading={true} no system default CircularProgress-equivalent

Slot rules: - iconStart and iconEnd may not both convey the same meaning. If both are present, they should be complementary (e.g., search icon + chevron-down). - When loading={true}, the indicator replaces iconStart (or center if no start icon). Label stays visible unless loadingPosition="center".

API

Prop Type Default Description
children ReactNode Label content. Required for accessible name unless aria-label provided.
variant "solid" \| "outline" \| "ghost" \| "link" "solid" Visual emphasis. solid is most prominent; link reads as text.
intent "primary" \| "neutral" \| "success" \| "warning" \| "danger" "primary" Semantic color. One primary+solid per surface.
size "sm" \| "md" \| "lg" "md" Dense → comfortable.
disabled boolean false Removes interactivity. Sets aria-disabled, dims appearance.
loading boolean false Shows spinner, blocks clicks, sets aria-busy="true".
loadingPosition "start" \| "end" \| "center" "start" Where the indicator sits. center hides label.
iconStart ReactNode Leading icon.
iconEnd ReactNode Trailing icon.
fullWidth boolean false Stretches to container width. Use sparingly — usually a sign of mobile or form layout.
href string If provided, renders <a> instead of <button>.
target "_self" \| "_blank" \| "_parent" \| "_top" Only with href.
asChild boolean false Render-as-child (Radix Slot pattern) — composes with <Link> from routers without nesting <a> inside <button>.
type "button" \| "submit" \| "reset" "button" HTML form behavior. Always "button" unless inside a form's submit slot — the default "submit" of bare <button> is the #1 form bug.
onClick (e: MouseEvent) => void Fires on click and on Enter/Space keypress.

States

State Trigger Visual
Default resting base styles per variant + intent
Hover mouse-over bg shift (solid: -8% L; outline: bg becomes 5% intent; ghost: bg becomes 8% intent)
Focus-visible keyboard tab 2px ring --color-focus-ring, 2px offset, 3:1 contrast both sides
Active press bg darken 12% from default
Disabled disabled={true} opacity 0.5, pointer-events: none, aria-disabled="true"
Loading loading={true} spinner replaces start icon; aria-busy="true"; click blocked
Read-only N/A buttons don't have read-only
Error parent form invalid unchanged — error state lives on form fields, not their submit button

hover styles must NOT fire on touch devices. Use @media (hover: hover) to gate.

Variants

variant

Value Visual Use
solid filled background, on-color text primary CTAs
outline transparent bg, intent border, intent text secondary actions
ghost no border, no bg, intent text on hover-bg tertiary actions, toolbar items
link text-only, underline on hover inline within text, low-emphasis nav

intent

Value Default solid bg On-color When
primary --color-primary-default --color-on-primary the main action of the surface
neutral --color-neutral-200 (light) / --color-neutral-700 (dark) --color-text-primary secondary action paired with primary
success --color-success white rare — confirm of irreversible positive action
warning --color-warning white or near-black rarely used as button bg; usually as outline
danger --color-error white destructive actions (delete, leave)

size

Size Height Font Padding Icon size
sm 32px 13px 6px 12px 14px
md 40px 14px 8px 16px 16px
lg 48px 16px 12px 20px 20px

Icon-only variants get square dimensions matching height.

Tokens consumed

--color-primary-default
--color-primary-hover
--color-primary-active
--color-on-primary
--color-neutral-200
--color-neutral-300
--color-text-primary
--color-error
--color-error-hover
--color-success
--color-warning
--color-focus-ring
--space-md
--space-sm
--space-lg
--radius-md
--font-size-sm
--font-size-base
--font-size-lg
--font-weight-medium
--transition-fast

If any of these don't exist in your token system, stop and add them before implementing the Button — don't inline values.

Accessibility

  • Semantic: <button type="button"> by default. <a> if href is set. Never <div onClick>.
  • Accessible name: from children (text content). If icon-only, must provide aria-label (e.g., aria-label="Close").
  • aria-disabled="true" when disabled (in addition to native disabled attribute, which removes from tab order and prevents events).
  • aria-busy="true" when loading.
  • aria-pressed is not for regular buttons — that's for toggle buttons. Don't add.
  • Keyboard:
  • Tab reaches.
  • Enter activates.
  • Space activates (when rendered as <button>, not when rendered as <a>Space scrolls anchors).
  • Escape does nothing on a button (escapes apply to overlays containing the button).
  • Focus indicator: 2px ring with --color-focus-ring, 2px offset. Must clear 3:1 against the button's resting background AND against the page background. Cite knowledge/a11y/contrast.md.
  • Touch target: minimum 44×44 hit area on mobile. If the visual height is sm (32px), extend hit area via padding or invisible expanded area.
  • High contrast mode (Windows): ensure border or fill remains visible. Test with forced-colors: active.
  • Reduced motion: omit fade/scale transitions on hover/active. State changes still happen, just instant.
  • Screen reader on loading: announcing "loading" on every Button activation is noisy. Prefer toast/live-region for "Saving…" instead, with the button just becoming disabled + visually showing spinner.

Code example

// Default — primary CTA
<Button onClick={handleSave}>
  Save changes
</Button>

// Loading mid-action
<Button onClick={handleSave} loading={isPending} disabled={isPending}>
  Save changes
</Button>

// Destructive
<Button intent="danger" onClick={handleDelete}>
  Delete project
</Button>

// Icon-only — REQUIRES aria-label
<Button variant="ghost" size="sm" aria-label="Close dialog">
  <CloseIcon />
</Button>

// As link (renders <a>, not <button>)
<Button asChild variant="link">
  <Link to="/pricing">See pricing</Link>
</Button>

// Outline secondary paired with solid primary
<div className="flex gap-2">
  <Button variant="outline">Cancel</Button>
  <Button>Confirm</Button>
</div>

Edge cases

  • Empty label: renders nothing visible. Treat as a bug — log warning in dev.
  • Long label: by default, the button grows to fit. If the parent constrains width, the label truncates with . Provide title attribute for the full text.
  • disabled and loading both true: precedence is disabled (no spinner). Document this — surprises if not specified.
  • Submit form with Enter: the form's first <button type="submit"> (or first <button> if no type) handles it. Always set type="button" on non-submit buttons in forms.
  • Inside a <a> parent (don't): invalid HTML. Use asChild to compose.
  • RTL: iconStart mirrors to the right, iconEnd to the left, padding swaps. CSS logical properties (padding-inline-start, etc.) handle this automatically with dir="rtl".
  • Print: hidden by default (@media print { display: none; }).

Don't

  • Don't use intent="primary" for more than one button on a surface.
  • Don't pass an icon as children when also using iconStart — pick one approach.
  • Don't disable the only path forward without offering an alternative ("submit" can't just be disabled with no error explanation).
  • Don't use onClick to navigate when href is appropriate — middle-click and right-click expectations break.
  • Don't put onClick on <a> — use <Button> or wire the link properly.
  • Don't put two solid + primary buttons next to each other. If both are essential, make one outline.

References

API choices made: - variant names: solid/outline/ghost/link over MUI's text/outlined/contained (clearer for non-Material teams) and over Ant's default/dashed/text/link (default is uninformative). - intent over color: intent reads as semantic (purpose), color reads as visual (which can clash with theme tokens). - asChild adopted from shadcn: cleaner composition with router <Link> than MUI's component prop. - loadingPosition adopted from MUI: covers a real ergonomic need (don't always want spinner at start).

Cross-reference