Button
A control that runs an action when it is pressed. The reference component: every other one in the library is this shape.
Overview
A control that runs an action when it is pressed. The reference component: every other one in the library is this shape.
First example
<Button onPress={submit}>Envoyer</Button>
Anatomy
<Button>
<Button.Spinner />
<Button.Icon />
<Button.Label />
</Button>
Button— the root. APressableFeedback, which owns the press state, resolves the recipe once and publishes the resolved styles to its slots. String children are wrapped in aButton.Labelautomatically.Button.Label— the text. Single-line by default: a control has a fixed height, so a label longer than the button truncates rather than deforming it.Button.Icon— an icon that takes the variant's colour and the size's scale with no prop. AcceptsIcon's three forms:as, a raw SVG child, orsource.Button.Spinner— the busy indicator.isLoadinginserts one when none is composed.
The view depth is one — PressableFeedback > (Text | Icon). There is no wrapper view.
Usage
Basic
Text children are wrapped for you.
<Button onPress={submit}>Envoyer</Button>
This works through a recursive stringify, not an inspection of the first child, so
<Button>{count} items</Button> is a label too — its children are the array [3, ' items'].
Composed
Use the slots when you need more than a label. JSX order is screen order: there is no
startContent or endContent, and no slot carries a margin of its own — the gap belongs to
the root.
<Button variant="danger">
<Button.Icon as={TrashIcon} />
<Button.Label>Supprimer</Button.Label>
</Button>
<Button variant="danger">
<Button.Label>Supprimer</Button.Label>
<Button.Icon as={TrashIcon} />
</Button>
Icon only
<Button isIconOnly accessibilityLabel="Supprimer">
<Button.Icon as={TrashIcon} />
</Button>
isIconOnly drops the horizontal padding and squares the button on its fixed height. There
is no width to compute. accessibilityLabel is required in practice — there is no text
to announce, and the component warns in development when it is missing.
Loading
<Button isLoading>Envoi…</Button>
The spinner is inserted at the head when none is composed. Compose one to place it yourself:
<Button isLoading>
<Button.Label>Envoi…</Button.Label>
<Button.Spinner />
</Button>
A loading button is not pressable, and announces as busy rather than as disabled.
As another element
asChild merges the button's props — the ref, the styles, the handlers — into its single
child instead of rendering a pressable of its own. The child is the button.
<Button asChild>
<Link href="/projects">Voir les projets</Link>
</Button>
Neither the auto-wrap nor the auto-spinner applies there: there is one child, and it is yours.
Sizes
<Button size="xs">xs</Button>
<Button size="sm">sm</Button>
<Button size="md">md</Button>
<Button size="lg">lg</Button>
size drives height, padding, gap, radius and type — never width. A button with no
width fills its parent in a column and hugs its content in a row, which is React Native's
own behaviour. There is no fullWidth prop — the deliberate case is width="100%", said
explicitly. To tighten one, compose:
<Row>
<Button>Envoyer</Button>
</Row>
<Button alignSelf="flex-start">Envoyer</Button>
Variants
Seven values, emphasis and intention in one flat union. Each names tokens and computes nothing.
<Button variant="primary">primary</Button>
<Button variant="secondary">secondary</Button>
<Button variant="default">default</Button>
<Button variant="tertiary">tertiary</Button>
<Button variant="ghost">ghost</Button>
<Button variant="danger">danger</Button>
<Button variant="danger-soft">danger-soft</Button>
variant | background | border | label |
|---|---|---|---|
primary | accent | — | accentForeground |
secondary | accentSoft | — | accentSoftForeground |
default | default | — | defaultForeground |
tertiary | transparent | border | foreground |
ghost | transparent | — | foreground |
danger | danger | — | dangerForeground |
danger-soft | dangerSoft | — | dangerSoftForeground |
The ladder descends by how much accent is left: primary is the full accent, secondary
its soft slice, default gives up the accent for the neutral fill, tertiary gives up
the fill for a border, ghost gives up that too. secondary is to primary what
danger-soft is to danger — the same soft slice of the same family, which is also why
a raw color lands on it with no rule of its own.
No success and no warning. danger earns its place because destruction is an
action a button performs; a success is an outcome and a warning is a state, and neither is
something you press — they belong to whatever reports status (Alert, Chip, Badge).
The theme keeps the tokens, so a button that genuinely needs that fill passes the tint:
<Button color={theme.colors.success}>Valider</Button>
Colour
One raw tint, and where it lands follows the variant rather than the prop:
<Button variant="primary" color="#7c3aed"> // violet background
<Button variant="ghost" color="#7c3aed"> // violet label, no background
<Button variant="tertiary" color="#7c3aed"> // violet border and label
It is a hex value, never a token. Its contrasted, soft and pressed slices are derived
in OKLab, so a free tint behaves exactly like accent — same ratios, same rendering. It is
deliberately outside the style cache: letting arbitrary values into the key would grow the
table with the colours people invent instead of with the finite combinations of tokens.
Style as props
R14 — loosening a button, giving it a width, changing a fill does not mean opening an object:
<Button padding={16} marginTop={8}>Envoyer</Button>
<Button width="100%" backgroundColor="#111">…</Button>
<Button>
<Button.Label fontSize={18} letterSpacing={1}>Envoyer</Button.Label>
</Button>
Full React Native names, and therefore full React Native values. padding rather than
p; padding={16} is 16 points, exactly as style would be. A prop carrying the RN key's
name while silently multiplying its value by a scale would be the most expensive trap in
the API. The scale stays one word away:
const t = useXAUITheme()
<Button padding={t.spacing(4)} borderRadius={t.radius.lg}>…</Button>
The set is not a list but a type — the node's style keys (ViewStyle on the root,
TextStyle on Button.Label) minus the directional forms R13 bans, which are not
exposed at all: paddingStart, never paddingLeft.
Button.Icon has them too, in ImageStyle, and they reach the source form only —
exactly as far as its style already does. The other two forms render a third-party
component or clone the caller's element, so there is no node of ours to style, and size
and color stay the escape hatch there.
Each one styles the node it is written on, never a descendant — which is what separates
this from the legacy customAppearance. width="100%" is what replaces fullWidth, said
explicitly, and height beats the height size chose, because style props resolve after
the recipe. That is an escape hatch, not the normal path: a control whose height you
straighten by hand is usually one whose size is missing from the scale.
color keeps its meaning here — the tint the variant places (above) on the root, and
TextStyle's color on Button.Label. Those coincide rather than conflict: in a text
component there is only one thing to tint. A raw fill on the root is backgroundColor,
which says what it does.
They resolve outside the style cache, and the slot's own style still wins — it stays the
last word for transform, a per-platform shadow, or a computed object.
Everything else goes through style
There are two appearance props, variant and color, and no third. A shadow, a border in
a different colour than the background, a gradient — those are a slot's own style:
<Button style={{ shadowColor: '#7c3aed', shadowOpacity: 0.4, shadowRadius: 12 }}>
<Button.Label style={{ letterSpacing: 1 }}>Envoyer</Button.Label>
</Button>
style also accepts Pressable's function form, (state) => style.
Root props
ButtonProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| variant | ButtonVariant | undefined | No | — |
| size | Size | undefined | No | Height, padding, gap, radius and type. Never width. |
| radius | RadiusKey | undefined | No | Overrides the radius `size` chose. Unset, a button is the shape its size implies. |
| color | string | undefined | No | A raw tint (`'#7c3aed'`), never a token (R7). Where it lands follows the variant: the background of a `primary`, the label of a `ghost`, the border and label of a `tertiary`. Its contrasted, soft and pressed slices are derived in OKLab, so it behaves exactly like `accent` — which is also why it must be a hex value. |
| isDisabled | boolean | undefined | No | — |
| isLoading | boolean | undefined | No | Presses through as disabled, and inserts a `Button.Spinner` if none is composed. |
| isIconOnly | boolean | undefined | No | Drops the horizontal padding and squares the button on its fixed height. |
| style | StyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>) | No | R9 — `Pressable`'s function form as much as an object or an array. |
| children | ReactNode | No | — |
Slots
ButtonIconProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
ButtonLabelProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| children | ReactNode | No | — |
ButtonSpinnerProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| style | StyleProp<ViewStyle> | No | — |
| animation | boolean | undefined | No | `false` stops the rotation. The ring stays, so the button does not change size. |
Variants, sizes and colour
Accessibility
accessibilityRoleis"button"by default and stays overridable.accessibilityStatecarriesdisabledandbusy, merged with any state you pass — addingexpandedorselecteddoes not erase them.- An icon-only button needs an
accessibilityLabel. There is no text to announce, and the component warns in development when one is missing. - The label is real
Text, so it is read, and it truncates rather than reflowing.
Migration from legacy
| Legacy | v1 |
|---|---|
themeColor="primary" variant="solid" | variant="primary" |
themeColor="danger" variant="solid" | variant="danger" |
variant="bordered" | variant="tertiary" |
variant="light" | variant="ghost" |
variant="flat" / "faded" | variant="secondary", or the -soft of the intention |
startContent={<Icon />} | <Button.Icon /> before <Button.Label /> |
endContent={<Icon />} | <Button.Icon /> after <Button.Label /> |
customAppearance={{ container, label }} | style on the root, style on Button.Label |
fullWidth | removed — that is already the default in a column |
spinnerPlacement="end" | compose <Button.Spinner /> after the label |
elevation | style={{ ...theme.shadows.surface }} |
<IconButton icon={…} /> | <Button isIconOnly accessibilityLabel="…"> |
Implementation notes
Touch feedback
A button scales, and mounts no overlay. That is a decision rather than an omission: the
recipe already paints the variant's own …Pressed token under the finger, so a wash or a
wave on top would darken it twice. A component picks one pressed treatment, never both —
and there is no prop here to pick the other, because there is nothing to pick between.
animation={false} renders a different component rather than the same one with a branch
inside — no Reanimated hook is reached at all. 'disable-all' does the same for every
descendant, which is how a long list switches its rows off with one prop.
A button that genuinely needs a wave is a different component, not a prop away: compose it
from PressableFeedback directly, with a recipe whose pressed state paints nothing. See
the PressableFeedback page.
Extending it
The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read:
import { useButton } from '@xaui/native/button'
function ButtonBadge({ children }) {
const { labelStyle, isDisabled } = useButton()
return <Text style={labelStyle}>{children}</Text>
}
Used outside a <Button> it throws by name, pointing at the misplaced component rather
than failing three frames later on an undefined style.