Button
The heart of the kit. One control that becomes whatever the moment asks for: polymorphic 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 is inert: no pointer, no ripple, no tab stop |
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 |
Button.IconLabel
| prop | type | default | |
|---|---|---|---|
icon | ReactNode | — | usually a Button.Icon |
label | ReactNode | — | usually a Button.Label |
side? | ButtonIconLabelSide | "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 |
Variables
| token | |
|---|---|
--margo-shadow-button | its elevation, declared for both themes |
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>
<Button onClickBlur={handleDelete}>
<Button.IconLabel icon={<Button.Icon icon={<MdDelete />} />} label={<Button.Label label="delete" />} />
</Button>
<Button onClickBlur={handleSettings}>
<Button.IconLabel icon={<Button.Icon icon={<MdSettings />} />} label={<Button.Label label="settings" />} />
</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>
<Button active onClickBlur={handleSelect}>
<Button.Label label="selected" />
</Button>
<Button clickable={false}>
<Button.Label label="not clickable" />
</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" onClickBlur={handleCopy}>
<Button.Icon icon={<MdContentCopy />} />
</Button>
<Button aria-label="delete" onClickBlur={handleDelete}>
<Button.Icon icon={<MdDelete />} />
</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 onClickBlur={handleContinue}>
<Button.IconLabel
reverse
icon={<Button.Icon icon={<MdArrowForward />} />}
label={<Button.Label label="continue" />}
/>
</Button>
<Button onClickBlur={handleDraft}>
<Button.IconLabel
reverse
side="start"
icon={<Button.Icon icon={<MdSave />} />}
label={<Button.Label label="save draft" />}
/>
</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 onClickBlur={handleSettings}>
<Button.IconLabel
side="end"
icon={<Button.Icon icon={<MdSettings />} />}
label={<Button.Label label="side end" />}
/>
</Button>
<Button onClickBlur={handleSettings}>
<Button.IconLabel
side="start"
icon={<Button.Icon icon={<MdSettings />} />}
label={<Button.Label label="side start" />}
/>
</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 takes the button out of reach entirely — no pointer, no tab stop — while leaving it 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 onClickBlur={handleActive}>
<Button.IconLabel icon={<Button.Icon icon={<MdSettings />} />} label={<Button.Label label="active" />} />
</Button>
<Button open onClickBlur={handleOpen}>
<Button.IconLabel icon={<Button.Icon icon={<MdSave />} />} label={<Button.Label label="open" />} />
</Button>
<Button clickable={false}>
<Button.IconLabel icon={<Button.Icon icon={<Spinner />} />} label={<Button.Label label="inert" />} />
</Button>
<Button disabled>
<Button.IconLabel icon={<Button.Icon icon={<MdDelete />} />} label={<Button.Label label="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} and disabled both mark the button inert, which removes the pointer, the tab stop and the element from the accessibility tree in one attribute; the second also dims it and sets aria-disabled, and is the one to reach for when the control is genuinely unavailable rather than merely busy.