Button — spec¶
Status: example artifact for
component-spec-writerskill Generated by:skills/component-spec-writerskill, 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>ifhrefis set. Never<div onClick>. - Accessible name: from
children(text content). If icon-only, must providearia-label(e.g.,aria-label="Close"). aria-disabled="true"whendisabled(in addition to nativedisabledattribute, which removes from tab order and prevents events).aria-busy="true"whenloading.aria-pressedis not for regular buttons — that's for toggle buttons. Don't add.- Keyboard:
Tabreaches.Enteractivates.Spaceactivates (when rendered as<button>, not when rendered as<a>—Spacescrolls anchors).Escapedoes 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
…. Providetitleattribute for the full text. disabledandloadingboth true: precedence isdisabled(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 settype="button"on non-submit buttons in forms. - Inside a
<a>parent (don't): invalid HTML. UseasChildto compose. - RTL:
iconStartmirrors to the right,iconEndto the left, padding swaps. CSS logical properties (padding-inline-start, etc.) handle this automatically withdir="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
onClickto navigate whenhrefis appropriate — middle-click and right-click expectations break. - Don't put
onClickon<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¶
- Ant Design:
refs/ant-design/components/button/Button.tsx— most exhaustive prop coverage; usestype(legacy) andvariant(modern) in parallel, withcoloras a separate axis. - MUI:
refs/mui/packages/mui-material/src/Button/Button.d.ts—variant(text/outlined/contained),color(semantic),loadingPosition. The most thoroughly documented. - shadcn-ui:
refs/shadcn-ui/apps/v4/registry/new-york-v4/ui/button.tsx— Tailwindcvapattern,asChildvia Radix Slot, simplest API surface. Inspired theiconStart/iconEndandasChildchoices in this spec.
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¶
- knowledge/components/INDEX.md — cross-library component index
- knowledge/a11y/keyboard-and-focus.md — keyboard contract
- knowledge/a11y/contrast.md — WCAG ratios