Button

A pressable action, with composed icons and a loading state that costs no layout.

import { Button } from "@delacour/native-ui/button";
<Button onPress={save}>Save</Button>

A string child is wrapped in a Button.Label automatically. React Native crashes on bare text outside a <Text>, so every component here that takes free-form children does the same.

Variants

Variants
Variants
import { BUTTON_VARIANTS, Button } from "@delacour/native-ui/button";import type { ReactElement } from "react";import { View } from "react-native";export function Demo(): ReactElement {	return (		<View className="gap-3">			{BUTTON_VARIANTS.map((variant) => (				<Button key={variant} testID={`variant-${variant}`} variant={variant}>					{variant}				</Button>			))}		</View>	);}
<Button variant="primary">Continue</Button>

The strongest action on the screen. One per view.

Sizes

Sizes
Sizes
import { BUTTON_SIZES, Button } from "@delacour/native-ui/button";import type { ReactElement } from "react";import { View } from "react-native";export function Demo(): ReactElement {	return (		<View className="gap-3">			{BUTTON_SIZES.map((size) => (				<Button key={size} size={size} testID={`size-${size}`}>					size {size}				</Button>			))}		</View>	);}
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>

A size is one axis driving four values: the height (--spacing-button-*), the label's type scale (--text-button-*), the icon step (--spacing-icon-*) and the horizontal padding.

Icons

Both icons, every variant
Both icons, every variant
import { BUTTON_VARIANTS, Button } from "@delacour/native-ui/button";import { Icon } from "@delacour/native-ui/icon";import { IconArrowRight, IconHeart } from "@delacour/native-ui/icons/central";import type { ReactElement } from "react";import { View } from "react-native";export function Demo(): ReactElement {	return (		<View className="gap-3">			{BUTTON_VARIANTS.map((variant) => (				<Button key={variant} testID={`both-${variant}`} variant={variant}>					<Icon icon={IconHeart} />					<Button.Label>{variant}</Button.Label>					<Icon icon={IconArrowRight} />				</Button>			))}		</View>	);}
Icon only
Icon only
import { BUTTON_SIZES, Button } from "@delacour/native-ui/button";import { Icon } from "@delacour/native-ui/icon";import { IconHeart, IconPlusMedium, IconTrashCan } from "@delacour/native-ui/icons/central";import type { ReactElement } from "react";import { View } from "react-native";export function Demo(): ReactElement {	return (		<View className="flex-row items-center gap-3">			{BUTTON_SIZES.map((size) => (				<Button accessibilityLabel={`Favourite ${size}`} isIconOnly key={size} size={size} testID={`favourite-${size}`}>					<Icon icon={IconHeart} />				</Button>			))}			<Button accessibilityLabel="Delete" isIconOnly testID="delete" variant="danger-soft">				<Icon icon={IconTrashCan} />			</Button>			<Button accessibilityLabel="Add" isIconOnly testID="add" variant="outline">				<Icon icon={IconPlusMedium} />			</Button>		</View>	);}

Icons are composed, never passed as props. Put an Icon in the children, before or after the label.

import { Icon } from "@delacour/native-ui/icon";
import { IconArrowRight, IconTrash } from "@delacour/native-ui/icons/central";

<Button onPress={next}>
  <Button.Label>Continue</Button.Label>
  <Icon icon={IconArrowRight} />
</Button>

<Button variant="danger" onPress={remove}>
  <Icon icon={IconTrash} />
  <Button.Label>Delete</Button.Label>
</Button>

The button wraps its subtree in an IconDefaultsProvider carrying the size and the variant's foreground token, so a bare <Icon> comes out right with nothing said at the call site. An explicit size or color on the icon still wins.

Button.StartContent and Button.EndContent are for wrapping leading or trailing content that is not an Icon.

Icon-only

<Button isIconOnly accessibilityLabel="Favourite" variant="ghost">
  <Icon icon={IconHeart} />
</Button>

Always pair isIconOnly with an accessibilityLabel. There is no text for a screen reader to fall back on.

Loading

<Button isLoading onPress={save}>Save</Button>

isLoading composes a Spinner in and blocks presses.

The spinner replaces the icon, it does not join it. It takes the place of the composed Icon on the side spinnerPlacement names — the first at start, the last at end — so the label does not shift when work begins and shift back when it ends. The swap costs no layout because both glyphs are drawn at the button's own size-icon-* token. Only a bare Icon is swapped; a Button.StartContent wraps content of unknown height, and replacing one could resize the button.

Placement

<Button isLoading spinnerPlacement="start">Save</Button>
<Button isLoading spinnerPlacement="end">Save</Button>
<Button isLoading spinnerPlacement="only">Save</Button>

only drops the children and centres the spinner in the footprint the button already has, carrying the label onto accessibilityLabel so a screen reader still has a name to read. It does not square the button on its own — pair it with isIconOnly when a square is what you want.

Loading is not disabled

isLoading blocks the press and announces the button as busy, but keeps full contrast. The spinner already says the press landed, and dimming reads as "this control is unavailable". Pass isDimmedWhileLoading to opt into the faded treatment.

Width in a content-width container

A stretched button keeps its width while loading, because only keeps its footprint. Inside a flex-row it still shrinks to the spinner, and that snap is un-animated on purpose: Pressable's Animated.View already runs a useAnimatedStyle on opacity and transform, and a native layout transition on the same view fights it for prop ownership. Pin the width (w-full, min-w-*) if you need it stable.

Press feedback and haptics

A button is a Pressable. feedback, haptic, pressedScale and the rest are inherited rather than restated; only the default differs — scale.

<Button feedback="fade">Fade on press</Button>
<Button haptic="selection">With haptic</Button>
<Button pressedScale={0.92}>Deeper press</Button>

Do not add ripple, ink, glow or highlight overlays. There are no wash layers on pressables in this library.

Reading the button's state

import { useButton } from "@delacour/native-ui/button";

function ButtonCount({ count }: { count: number }) {
  const { variant, size, isDisabled } = useButton();
  // …
}

Anatomy

Compound parts
Compound parts
import { Button } from "@delacour/native-ui/button";import { Icon } from "@delacour/native-ui/icon";import { IconArrowRight, IconHeart } from "@delacour/native-ui/icons/central";import type { ReactElement } from "react";export function Demo(): ReactElement {	return (		<Button testID="compound" variant="outline">			<Button.StartContent>				<Icon color="danger" icon={IconHeart} size={18} />			</Button.StartContent>			<Button.Label className="text-danger">Custom label colour</Button.Label>			<Button.EndContent>				<Icon color="muted-foreground" icon={IconArrowRight} size={18} />			</Button.EndContent>		</Button>	);}

Prop

Type

API

ButtonProps

Extends PressableProps, minus busy, children, disabled, pressedOpacity and pressedScale.

Prop

Type

ButtonLabelProps

React Native's TextProps, plus className.

useButton()

Prop

Type

Exported constants

import {
  BUTTON_VARIANTS,
  BUTTON_SIZES,
  BUTTON_SPINNER_PLACEMENTS,
  BUTTON_FOREGROUND_TOKEN,
  buttonVariants,
} from "@delacour/native-ui/button";

BUTTON_FOREGROUND_TOKEN maps each variant to the theme token its icons and text are drawn in. buttonVariants is the tv() slot set — useful when building a component that has to match a button exactly.

On this page