Button

Free · MITReact Native · Expo

A press-responsive button with haptics that runs async actions on its own: it gathers into a spinner, then opens into a success or error state.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/button.json

Then run npx expo install --fix so react-native-reanimated match your Expo SDK. Files land in components/goodui-native/button.

Usage

import { Button } from "@/components/goodui-native/button/button";

export function SaveBar({ save }: { save: () => Promise<void> }) {
  return (
    <Button size="lg" successLabel="Saved" onPress={save}>
      Save changes
    </Button>
  );
}

When to use

Use it for
  • The primary or secondary action of a screen: continue, save, send, subscribe.
  • Actions backed by a request: return the promise from onPress and the button shows pending, success and error itself.
  • Short labels that start with a verb.
Reach for something else
  • Irreversible actions such as deleting an account: use hold-to-confirm.
  • Switching between views: use segmented-control.
  • Toolbar icons without a label: use a plain Pressable with an accessibilityLabel.

Examples

Variants and sizes

<Button>Continue</Button>
<Button variant="secondary">Not now</Button>
<Button variant="ghost">Learn more</Button>
<Button variant="destructive" size="sm">Delete</Button>

A request that can fail

<Button variant="secondary" errorLabel="Couldn't send" onPress={() => api.sendInvite(email)}>
  Send invite
</Button>

API reference

PropTypeDefault
children*
Button label. Sentence case, starts with a verb.
string—
onPress
Return a promise and the button runs the whole result on its own: it gathers into a spinner while the promise is pending, then opens into a success or error state. Anything else (or nothing) is an instant action.
() => unknown—
variant
`primary` is the one main action; `secondary` and `ghost` are quieter; `destructive` is a soft red.
"primary" | "secondary" | "ghost" | "destructive""primary"
size
Heights 36, 48 and 56 pt. Small buttons keep a 44 pt touch target.
"sm" | "md" | "lg""md"
leading
Icon before the label, 18 to 20 pt, colored to match the label.
ReactNode—
disabled
Dims the control and ignores presses.
booleanfalse
successLabel
Short text shown with the check after the promise resolves. Omit to show the check alone.
string—
errorLabel
Short text shown when the promise rejects. Same as `labels.error`.
string—
haptic
Haptic on press. `false` turns it off for this button.
HapticName | false"tap"
accessibilityLabel
Overrides the label read by screen readers.
string—
accessibilityHint
Describes what happens on activation, read after the label.
string—
labels
Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide.
Partial<ButtonLabels>—
style
Style for the outer container.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • Press: scales to 0.97 on the snappy spring and fills an overlay, both from touch down.
  • Pending: the surface gathers into a circle on the morph spring; the layout never shifts.
  • Result: color leads, the surface opens, then the check draws itself or the error appears; an error also shakes.
  • Reduced motion: no scale, no shape change, no shake; states crossfade.
  • Haptic: tap on press (configurable with the haptic prop)
  • Haptic: success when the promise resolves
  • Haptic: error when it rejects
  • All animation runs on the UI thread through Reanimated shared values; React renders only on state changes.

Accessibility

  • accessibilityRole is button; disabled and busy are reported through accessibilityState.
  • The accessibility label follows the state: 'Save changes, in progress', then the success or error label.
  • Results are announced with AccessibilityInfo.announceForAccessibility.
  • Heights below 44 pt get a hitSlop that brings the touch target to 44 pt.

Notes for AI

  • Return the promise from onPress to get loading, success and error for free. Do not add your own spinner or isLoading state.
  • The label is children and must be a string. Put icons in the leading prop.
  • It stretches in a column and hugs its label in a row. Use style={{ alignSelf: 'flex-start' }} to hug inside a column.
  • Use one primary button per screen; secondary and ghost for the rest.