Button
Starts an action, like running an agent or saving a change.
Examples
Issue refund
Conversations
12Styles
primarydefault
secondary
ghost
destructive
link
Sizes
xs
sm
defaultdefault
lg
icon-xs
icon-sm
icon
icon-lg
States
rest
hover
focus
active
disabled
pending
Usage
When to use it
- Use
- The person changes something: create, save, publish, refund, delete.
- Use
- A row or toolbar repeats one small action on every item.
- Use
- A single filter stays on until turned off (pressed mode).
- Not for
- The control turns a setting on or off at once. Use Switch.
- Not for
- The person picks one of two to five views. Use Segmented control.
- Not for
- More than three actions compete for one row. Use Dropdown menu.
Rules
12 rules
- Must
- Use one variant="primary" per view; make every other button secondary or ghost. Because the ink fill marks the next step, so a view has one; primary is the default variant, and two bare Buttons give the view two ink fills and no clear next step.
- Must
- Show destructive with variant="destructive", a fail edge and fail text; never a red fill. Because a red fill is the loudest shape on the page and outshouts the primary; trouble is written, not painted.
- Must
- Set pending and pendingLabel; never swap the children while an action runs. Because swapped children change the width and the accessible name mid-press, while pendingLabel shares the idle label's cell.
- Must
- Pass pending as a boolean from the first render on any button that can pend. Because Button reserves the spinner's room only when pending is defined, so undefined then true makes the box jump.
- Must
- Give every icon size an aria-label. Because the button holds only a glyph, so a screen reader announces "button" and the dev check logs an error.
- Must
- Set type="submit" on the Button that submits a form. Because Base UI defaults to type="button", so the press and Enter in a field never submit the form.
- Never
- Wrap a Button in an
<a>. Use Link, or render the Button as an anchor. Because a button inside a link is invalid HTML, takes two tab stops and announces as both a link and a button. - Never
- Pass arrow to a variant="link" Button; it acts on this page, and only an external Link carries an arrow. Because an arrow ending link text breaks anti-slop-no-arrow-links and reads as leaving the page.
- Must
- Pass only placement classes in className, never fills, colors or heights. Because a hand-set fill skips the variant's hover, disabled and forced-colors states, and a fixed height clips text at 200% zoom.
- Should
- Say why a button is disabled in text beside it, not in a tooltip on it. Because a disabled button takes no hover on touch, so a tooltip on it never shows there.
- Should
- Label with a verb and its object in sentence case, 40 characters or fewer.
- Should
- Let a row of buttons wrap with flex-wrap; buttons never shrink. Because Button is shrink-0 and whitespace-nowrap, so a row that cannot wrap overflows a narrow column.
Accessibility
5 checks
- Focus shows a 2px ink outline on :focus-visible, on every variant.
- Name icon sizes with aria-label; the glyph is aria-hidden.
- Pending sets aria-busy and keeps the idle name.
- Pressed mode renders aria-pressed; the pressed fill is ink, 17.1:1 for its label.
- Report results in a role="status" region, never in the label.
Edge cases
12 edge cases
- Long label
- Never wraps; the box grows past its container. Keep labels under 40 characters.
- Row narrower than its buttons
- Buttons never shrink. Wrap the row with flex-wrap.
- Right to left
- Layout mirrors and the arrow flips.
- Zoom 200%
- Height grows with the text; the label stays inside.
- Slow request
- The box keeps its width, focus stays, repeats are blocked.
- Failed request
- Returns to rest with its name; report the error in a status region.
- Empty or unnamed icon button
- Development builds log it and set data-unnamed.
- Held Enter or double click
- Pending blocks repeats; the handler needs no guard.
- Inside a form
- Presses submit only with type="submit".
- Forced colors
- Non-link variants draw a ButtonText border; the focus outline survives.
- Disabled primary
- Fades the whole key with --dim-opacity, since an ink fill has no quieter colour that still reads as a button; the other variants keep their fill and set the label to text-disabled.
- Reduced motion
- No press scale; the spinner stays as a still ring.
API
11 props
variant'primary' | 'secondary' | 'ghost' | 'destructive' | 'link'default'primary'size'xs' | 'sm' | 'default' | 'lg' | 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'default'default'ButtonSize size'xs' | 'sm' | 'default'pendingbooleanpendingLabelReactNodearrowbooleandefaultfalsepressedbooleankind'agent' | 'key' | 'run' | 'deploy' | 'model'focusableWhenDisabledbooleandefaultfalserenderReactElementclassNamestring
Tokens
35 tokens
--ink- oklch(var(--ink-c))
--button-primary-hover- oklch(var(--ink-c) / 0.86)
--button-ghost- transparent
--button-ghost-hover- oklch(var(--ink-c) / 0.05)
--line-control- oklch(var(--ink-c) / 0.16)
--line-frame- oklch(var(--ink-c) / var(--line-frame-alpha))
--fail-line- oklch(60% 0.236 26)
--fail-text- oklch(51% 0.180 26)
--fail-wash- oklch(94.5% 0.027 26)
--text-inverse- var(--paper)
--text-disabled- oklch(var(--ink-c) / 0.40)
--dim-opacity- 0.4
--surface-inverse- var(--ink)
--surface-pressed- oklch(var(--ink-c) / 0.08)
--elev-control- 0 1px 2px -1px oklch(var(--text-on-field-c) / calc(var(--shadow-alpha) * 0.6))
--key-primary-body- linear-gradient(180deg, color-mix(in oklch, var(--ink) 84%, white), color-mix(in oklch, var(--ink) 94%, black))
--key-primary-body-hover- linear-gradient(180deg, color-mix(in oklch, var(--ink) 76%, white), color-mix(in oklch, var(--ink) 90%, white))
--key-primary-edge- inset 0 2px 0 -1px oklch(1 0 0 / 0.2), inset 0 -2px 0 -1px oklch(0 0 0 / 0.3), inset 0 0 0 1px color-mix(in oklch, var(--ink) 88%, black), 0 1px 1px oklch(var(--text-on-field-c) / calc(var(--shadow-alpha) * 1.1)), 0 2px 5px -2px oklch(var(--text-on-field-c) / calc(var(--shadow-alpha) * 1.5))
--key-primary-edge-press- inset 0 0 0 1px color-mix(in oklch, var(--ink) 88%, black), inset 0 1px 2px oklch(0 0 0 / 0.42)
--key-primary-sheen- linear-gradient(90deg, transparent 14%, oklch(1 0 0 / 0.42) 50%, transparent 86%) top / 100% 1px no-repeat, linear-gradient(180deg, oklch(1 0 0 / 0.1), oklch(1 0 0 / 0) 55%)
--key-secondary-body- var(--paper)
--key-secondary-body-hover- oklch(var(--ink-c) / 0.03)
--key-secondary-edge- var(--elev-control)
--key-secondary-edge-press- inset 0 1px 2px oklch(var(--ink-c) / 0.12)
--control-xs- 24px
--control-sm- 28px
--control- 32px
--control-lg- 44px
--radius-soft- 6px
--line-focus- 2px
--border-focus- var(--ink)
--focus-offset- 2px
--dur-fast- 120ms
--dur-press- 60ms
--ease-step- cubic-bezier(0.2, 0, 0, 1)