Button
Button is the primary action element in Mosaic, used for form submissions and discrete user-triggered actions.
Playground
Props
| Prop | Type | Default | Value |
|---|---|---|---|
| color | 'primary' | 'neutral' | 'negative' | 'primary' | |
| variant | 'filled' | 'outline' | 'ghost' | 'link' | 'filled' | |
| size | 'sm' | 'md' | 'lg' | 'md' | |
| shape | 'default' | 'square' | 'circle' | 'default' | |
| fullWidth | boolean | false | |
| touchTarget | boolean | true | |
| className | string | — | — |
| style | CSSProperties | — | — |
Usage
import { Button } from '@clerk/ui/mosaic/components/button';
<Button
color='primary'
variant='filled'
size='md'
shape='default'
touchTarget
>
Click me
</Button>Examples
Sizes
size sets the height, and under a coarse pointer every size floors at 44px instead — a
fingertip is the same width whatever density the surrounding UI runs at, so the floor is a fixed
--cl-target-coarse rather than a step on the spacing scale a consumer can rescale. Icon buttons
take the floor on both axes, so a square button stays square rather than growing tall and narrow.
link opts out entirely: it reads as text, not a control. None of this is visible on a mouse —
it applies at @media (pointer: coarse).
Variants
Colors
color and variant are independent: color picks the palette, variant decides how much of
it to show. Every combination below is the same two props.
Hover and focus are part of the matrix — hover a cell, or tab through it, to see them. filled
shifts its fill and link underlines. outline and ghost share the same neutral hover fill,
except ghost under negative, which is the one cell that tints by color. The focus ring is
the same for every cell.
Shapes
Icons
An icon is a child, not a prop. Give it a placement and Icon reflects that as data-icon,
which the button selects on to tighten the padding on that edge — pass icons on either side, or
both, with nothing to declare on the button itself.
The tightened value isn't chosen by eye. An icon is centered in the button, so it already has
(height − icon size) / 2 of space above and below it; matching the inline side to that puts it
in a square cell — 8px at every size, against 12px of text padding at md and lg, 10px at sm.
The text side keeps the larger inset, since a run of text ends in a stem where an icon trails off.
The middle button in each row below carries no icon, for comparison.
Truncation
A button sizes to its own content and doesn't shrink, so most of the time there's nothing to
truncate. When its width is constrained — fullWidth in a narrow container, or an explicit
width — the label can't wrap to cope, because size fixes the height and a second line would
grow out of the button. It stays on one line and ends in an ellipsis instead.
Only text children truncate. Button wraps a text child in a span of its own to run the ellipsis against, since a bare text node is laid out in an anonymous box no selector can reach. Element children pass through as direct flex items, so an icon keeps its size and its padding while the label gives way around it.
Disabled
A disabled button keeps its resting fill and dims — it doesn't fall back to a gray of its own, so
which color and variant it is stays legible while it's unavailable. Hover and press are suppressed
by the styles, not by pointer-events: the button stays hit-testable, which is what makes
cursor: not-allowed render at all and what lets a wrapping tooltip explain why it's disabled.
That tooltip is worth adding — a disabled control with no explanation is a dead end.
Submitting
SubmitButton is a Button that defaults type to submit and adds isPending. Use it for
the button that commits a form; reach for plain Button everywhere else.
Press the button above to run a stand-in action: it goes pending for two seconds, then comes back.
While isPending, the label fades to zero opacity and a spinner centers over it. The label stays
mounted rather than being swapped out, so the button holds the width its content gives it — watch
that it doesn't resize across the flip — and nothing around it reflows. Every child sits in one
box, so an icon fades with its label instead of hanging on beside the spinner. That box is the slot
cl-button-content, so it can be targeted directly — .cl-button-content for the content row of
any submit button, .cl-button[data-pending] .cl-button-content for it mid-action.
A pending button is inert but not disabled: it carries aria-disabled, drops its pointer events
so hover and press stop firing, and cancels the press so the form can't be submitted twice. The
native disabled attribute would do all of that too, but it takes the button out of the tab order
mid-action — pulling focus away at the exact moment the spinner is announced. The state is also
reflected as data-pending for styling.
The spinner is delayed
The button becomes pending the instant isPending flips, but the spinner waits 300ms before it's
drawn, then stays up at least 200ms once it is. Plenty of actions resolve faster than a spinner
takes to read, and one that appears and vanishes inside a few frames registers as a glitch rather
than as progress.
Nothing about the pending state is delayed — only the pixels. Both buttons above go inert and announce themselves the moment they're pressed, which is what stops a double submit; the fast one simply finishes before its spinner is due.
Both numbers move with spinDelay. An action already known to be slow has nothing to gain by
waiting, so it can skip straight to the spinner:
<SubmitButton
isPending={isSubmitting}
spinDelay={{ delay: 0 }}
>
Save changes
</SubmitButton>What assistive tech gets
The spinner is decorative everywhere else in Mosaic, but here it is the only signal the action is
running, so it enters the accessibility tree as an indeterminate progressbar the moment
isPending flips — including during the delay above, when it's mounted but not yet drawn. That's
why the delay is opacity and not conditional rendering: visibility: hidden or display: none
would take it back out of the tree, and so would not rendering it. Fading the label with opacity
is the same call — it keeps the button named "Save changes" for the whole action instead of going
briefly nameless.
The indicator is named in its own right, via pendingLabel (default pending). It is not folded
into the button's name: progressbar is a range role, so name computation reads its value —
absent, since it's indeterminate — rather than its label, and a descendant one contributes nothing
to the button above it. pendingLabel is untranslated, so pass a localized string wherever the
surrounding copy is localized.
<SubmitButton
isPending={isSubmitting}
pendingLabel='Saving'
>
Save changes
</SubmitButton>The spinner is sized off the Icon scale, since it stands in for one. That scale stops at md,
so md and lg buttons share the larger ring.
Both the ring and its arc are mixed from currentColor, so the spinner reads on a filled
button's fill and on a bare surface alike without a color prop to keep in step with the button's.
Touch target
Every size is shorter than the 44px a fingertip needs, so under pointer: coarse the button grows
its hit area with an invisible overlay — the rendered size doesn't change, and nothing shifts on a
mouse. Default shape grows on the block axis only; square and circle grow on both, being as narrow
as they are short. link is text, not a control, and never takes one.
Opt out with touchTarget={false} where buttons sit close enough for the overlays to overlap — a
dense icon toolbar, a tight stack. Overlap isn't symmetrical: the later sibling's overlay covers
the edge of the one before it, so a tap there activates the wrong button.
<Button
shape='square'
touchTarget={false}
>
×
</Button>