Button

A polymorphic control that stays an icon at rest and reveals its label on hover, on focus, or on demand.

API

Props

proptypedefault
as?ElementType"button"the element or component to render
active?booleanfalseselected state, drawn as a gradient border
open?booleanfalsekeeps the label revealed, as hover would
clickable?booleantruewhen false, the button ignores the pointer, the ripple and the tab order
disabled?booleanfalseunavailable: inert, dimmed, aria-disabled and out of the tab order
onClick?MouseEventHandlercalled on click, before onClickBlur
onClickBlur?MouseEventHandlercalled on click, then the button loses focus 150ms later
…restprops of astyped against the chosen element

Button.Icon

proptypedefault
iconReactNodethe glyph, sized in a 1rem square
className?stringmerged over the defaults

Button.Label

proptypedefault
labelstringthe text, rendered lowercase
className?stringmerged over the defaults

Labels are rendered lowercase, which keeps a row of actions even however the strings were written. Where the text carries its own casing — a name, an acronym, a translated string that must not be touched — the class is merged and one className undoes it.

Button.IconLabel

proptypedefault
iconReactNodeusually a Button.Icon
labelReactNodeusually a Button.Label
side?"start" | "end""end"which side the label expands towards
reverse?booleanfalseswaps which of the two is the fixed one
gap?string"0.5rem"space between icon and label once revealed
className?stringmerged over the defaults

The reveal is a grid whose second track animates from 0fr to 1fr, with the gap growing alongside it. Nothing is measured and nothing is mounted or unmounted: the label is always in the DOM, and only its track has width. That is what keeps the animation on the compositor and the accessible name stable — a button announces the same name whether or not you are hovering it.

Examples

Usage

The default arrangement is an icon that stays and a label that appears: at rest the button is a square, and the text arrives on hover or on focus. It is what lets a row of actions stay compact without becoming a row of guesses.

tsx
<Button onClickBlur={handleSave}>
  <Button.IconLabel
    icon={<Button.Icon icon={<MdSave />} />}
    label={<Button.Label label="save" />}
  />
</Button>
previewhover them

Text only

Mount a Button.Label on its own and the button becomes a plain text control: no icon, no reveal, the width it needs from the start. This is the form for the actions a form or a dialog ends with, where the label is the whole point and hiding it would be theatre.

tsx
<Button onClickBlur={handleSubmit}>
  <Button.Label label="submit" />
</Button>
previewnothing to reveal

Icon only

The opposite form: a Button.Icon alone. It renders no text at all, so it must carry an aria-label — without one it is announced as an unnamed button. Use it where the glyph is unambiguous and the space is genuinely tight, like the copy button on the snippets of this site.

tsx
<Button aria-label="copy">
  <Button.Icon icon={<MdContentCopy />} />
</Button>
previewnamed for screen readers

Reverse

reverse inverts which of the two is the fixed one: the label stays and the icon is what appears. The button then reads as a word that grows an arrow, rather than a symbol that grows a caption.

tsx
<Button>
  <Button.IconLabel
    reverse
    icon={<Button.Icon icon={<MdArrowForward />} />}
    label={<Button.Label label="continue" />}
  />
</Button>
previewthe icon is what arrives

Side

side chooses the direction of the reveal. With end, the default, the second element opens to the right; with start it opens to the left, which is what a button pinned to the right edge of a bar needs so it grows inwards instead of pushing against the edge.

tsx
<Button>
  <Button.IconLabel side="start" icon={} label={} />
</Button>
previewthe same button, opening both ways

States

active swaps the resting border for the primary gradient. open keeps the label revealed as hover would, for a button that controls something currently open — a menu, a panel. clickable set to false drops pointer events entirely, leaving the button looking untouched — the shape of a loading state. disabled does the same and dims it to half opacity, so the control reads as unavailable rather than merely busy.

tsx
<Button active></Button>
<Button open></Button>
<Button clickable={false}></Button>
<Button disabled></Button>
previewopen stays revealed without hovering

Details

Polymorphism

as defaults to button and accepts anything: an anchor, a router Link, a component of your own. The props follow the choice, so to is typed when it is a Link and rejected when it is a native button.

tsx
<Button as={Link} to="/margo-ui" active={isActive} aria-current={isActive ? "page" : undefined}>
  <Button.Label label="documentation" />
</Button>

onClickBlur

A clicked button keeps focus, and with it the focus ring, long after the pointer has moved on — which reads as a control still waiting for something. onClickBlur runs your handler and then blurs the element 150ms later, enough for the ripple to be seen.

tsx
<Button onClickBlur={() => {}}></Button>

An empty handler is a legitimate use: you are asking for the blur, not for the callback. It never replaces onClick, which still runs first if both are given, and it leaves keyboard focus alone because the blur only follows a real click.

The label is lowercase

tsx
<Button.Label label="Save draft" className="normal-case" />

When to reverse

It is the right form when the action is already clear from its name and the icon adds emphasis rather than meaning — a primary action at the end of a flow. When the icon is what identifies the action, keep the default: an icon that only exists on hover cannot be scanned.

The states are visual only

active and open are visual only: neither is announced, so pair active with aria-pressed or aria-current when it carries meaning. clickable={false} removes the pointer and the tab stop but tells assistive technology nothing, which is what a busy control wants; disabled adds aria-disabled on top, and is the one to reach for when the control is genuinely unavailable.