BetaActions

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. A PressableFeedback, which owns the press state, resolves the recipe once and publishes the resolved styles to its slots. String children are wrapped in a Button.Label automatically.
  • 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. Accepts Icon's three forms: as, a raw SVG child, or source.
  • Button.Spinner — the busy indicator. isLoading inserts 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>
variantbackgroundborderlabel
primaryaccentaccentForeground
secondaryaccentSoftaccentSoftForeground
defaultdefaultdefaultForeground
tertiarytransparentborderforeground
ghosttransparentforeground
dangerdangerdangerForeground
danger-softdangerSoftdangerSoftForeground

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.

PropTypeRequiredDescription
variantButtonVariant | undefinedNo
sizeSize | undefinedNoHeight, padding, gap, radius and type. Never width.
radiusRadiusKey | undefinedNoOverrides the radius `size` chose. Unset, a button is the shape its size implies.
colorstring | undefinedNoA 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.
isDisabledboolean | undefinedNo
isLoadingboolean | undefinedNoPresses through as disabled, and inserts a `Button.Spinner` if none is composed.
isIconOnlyboolean | undefinedNoDrops the horizontal padding and squares the button on its fixed height.
styleStyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>)NoR9 — `Pressable`'s function form as much as an object or an array.
childrenReactNodeNo

Slots

ButtonIconProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

This slot adds nothing to the props of its React Native node.

ButtonLabelProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

ButtonSpinnerProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
styleStyleProp<ViewStyle>No
animationboolean | undefinedNo`false` stops the rotation. The ring stays, so the button does not change size.

Variants, sizes and colour

This component adds no visual axis of its own. The values it does take are in the generated types above and in the live demo.

Accessibility

  • accessibilityRole is "button" by default and stays overridable.
  • accessibilityState carries disabled and busy, merged with any state you pass — adding expanded or selected does 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

Legacyv1
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
fullWidthremoved — that is already the default in a column
spinnerPlacement="end"compose <Button.Spinner /> after the label
elevationstyle={{ ...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.