Button
A polymorphic control that stays an icon at rest and reveals its label on hover, on focus, or on demand.
API
Props
| prop | type | default | |
|---|---|---|---|
as? | ElementType | "button" | the element or component to render |
active? | boolean | false | selected state, drawn as a gradient border |
open? | boolean | false | keeps the label revealed, as hover would |
clickable? | boolean | true | when false, the button ignores the pointer, the ripple and the tab order |
disabled? | boolean | false | unavailable: inert, dimmed, aria-disabled and out of the tab order |
onClick? | MouseEventHandler | — | called on click, before onClickBlur |
onClickBlur? | MouseEventHandler | — | called on click, then the button loses focus 150ms later |
…rest | props of as | — | typed against the chosen element |
Button.Icon
| prop | type | default | |
|---|---|---|---|
icon | ReactNode | — | the glyph, sized in a 1rem square |
className? | string | — | merged over the defaults |
Button.Label
| prop | type | default | |
|---|---|---|---|
label | string | — | the text, rendered lowercase |
className? | string | — | merged 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
| prop | type | default | |
|---|---|---|---|
icon | ReactNode | — | usually a Button.Icon |
label | ReactNode | — | usually a Button.Label |
side? | "start" | "end" | "end" | which side the label expands towards |
reverse? | boolean | false | swaps which of the two is the fixed one |
gap? | string | "0.5rem" | space between icon and label once revealed |
className? | string | — | merged 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.
<Button onClickBlur={handleSave}>
<Button.IconLabel
icon={<Button.Icon icon={<MdSave />} />}
label={<Button.Label label="save" />}
/>
</Button>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.
<Button onClickBlur={handleSubmit}>
<Button.Label label="submit" />
</Button>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.
<Button aria-label="copy">
<Button.Icon icon={<MdContentCopy />} />
</Button>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.
<Button>
<Button.IconLabel
reverse
icon={<Button.Icon icon={<MdArrowForward />} />}
label={<Button.Label label="continue" />}
/>
</Button>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.
<Button>
<Button.IconLabel side="start" icon={…} label={…} />
</Button>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.
<Button active>…</Button>
<Button open>…</Button>
<Button clickable={false}>…</Button>
<Button disabled>…</Button>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.
<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.
<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
<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.