# Button

> Button with Variants (primary, secondary, outline, ghost, destructive, link), Sizes, an icon slot, loading and status; icon-only requires aria-label.

Component · core. Registry item `button`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/button.json

## Installation

```sh
npx nativecn-cli@latest add button
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [pressable](https://nativecn.dev/docs/primitives/pressable), [text](https://nativecn.dev/docs/components/text), [icon](https://nativecn.dev/docs/components/icon), [spinner](https://nativecn.dev/docs/components/spinner), [announce](https://nativecn.dev/docs/primitives/announce).

Packages: `lucide-react-native@^1.51.0`, `react-native-svg`, `react-native-reanimated`, `react-native-worklets`.

## Usage

### Button demo

Every Variant and Size, an icon, icon-only, loading, status and disabled.

```tsx
import { ArrowRight, Trash } from "lucide-react-native";
import { useState } from "react";
import { View } from "react-native";

import { Button } from "@/components/button";
import { createStyles } from "@/theme";

/** Variants, Sizes, an icon, icon-only, loading and status. */
export default function ButtonDemo() {
  const styles = useStyles();
  const [saving, setSaving] = useState(false);
  const [saved, setSaved] = useState(false);

  const save = () => {
    setSaving(true);
    setTimeout(() => {
      setSaving(false);
      setSaved(true);
    }, 800);
  };

  return (
    <View style={styles.stack}>
      <View style={styles.row}>
        <Button label="Primary" />
        <Button label="Secondary" variant="secondary" />
        <Button label="Outline" variant="outline" />
      </View>
      <View style={styles.row}>
        <Button label="Ghost" variant="ghost" />
        <Button label="Delete" variant="destructive" />
        <Button label="Learn more" variant="link" />
      </View>
      <View style={styles.row}>
        <Button label="Small" size="sm" />
        <Button label="Large" size="lg" />
        {/* Icon-only: aria-label is required. */}
        <Button icon={Trash} variant="outline" aria-label="Delete draft" />
      </View>
      <Button label="Continue" icon={ArrowRight} iconPosition="end" />
      {/* The screen sets and clears status; the Button never resets it. */}
      <Button
        label={saved ? "Saved" : "Save"}
        loading={saving}
        status={saved ? "success" : undefined}
        onPress={save}
      />
      <Button label="Disabled" disabled />
    </View>
  );
}

const useStyles = createStyles((t) => ({
  stack: { gap: t.spacing[3] },
  row: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: t.spacing[2] },
}));
```

## Props

| Prop | Type and description |
|---|---|
| `...PressableProps` | Pressable Primitive props pass through (onPress, onLongPress, haptic, testID, ...) |
| `label` | string: the visible text and accessible name (required unless icon-only) |
| `icon` | LucideIcon: shown in the icon slot |
| `iconPosition` | "start" (default) \| "end": where the icon slot sits |
| `variant` | "primary" (default) \| "secondary" \| "outline" \| "ghost" \| "destructive" \| "link" |
| `size` | "sm" \| "md" (default) \| "lg" |
| `loading` | boolean: a Spinner takes the icon slot, the label stays, presses are blocked, announced busy |
| `status` | "error" \| "success": a status icon in the icon slot, announced once; the screen clears it |
| `disabled` | boolean: dimmed, presses blocked, announced disabled |
| `aria-label` | string: required for icon-only Buttons (icon and no label) |
| `style` | ViewStyle, layout only (e.g. full width), merged last |

## Variants

- `variant`: `primary`, `secondary`, `outline`, `ghost`, `destructive`, `link`
- `size`: `sm`, `md`, `lg`
- `status`: `error`, `success`

## Accessibility

- role button; the label is the accessible name.
- Icon-only Buttons are typed to require aria-label.
- The tap area is extended to 48 high, and to 48 wide for icon-only Buttons.
- disabled is announced as disabled and loading as busy; no press handler runs in either state.
- A new status is announced once ("Save: error"); the loading Spinner stays silent because the Button is already busy.
- The label uses the button text Variant, which caps the OS font scale at 1.5× when font scaling is on.

## Notes

Built on the Pressable Primitive: the pressed look comes from the button.pressed Style Slot. Precedence: disabled > loading > status. Make a Button full width with style={{ alignSelf: 'stretch' }}; there is no fullWidth prop. Buttons give no haptic tick by default.

## Source

### {components}/button.tsx

```tsx
import { Check, CircleAlert, type LucideIcon } from "lucide-react-native";
import { useEffect, useRef } from "react";
import type { StyleProp, ViewStyle } from "react-native";

import { Icon, type IconSize } from "@/components/icon";
import { Pressable, type PressableProps } from "@/components/primitives/pressable";
import { Spinner } from "@/components/spinner";
import { Text, type TextColor } from "@/components/text";
import { createStyles, type ColorRole } from "@/theme";
import { announce } from "@/utils/announce";

/** Each Variant's Colour Roles: fill, content and (optional) border. */
const variants = {
  primary: { background: "primary", foreground: "primaryForeground" },
  secondary: { background: "secondary", foreground: "secondaryForeground" },
  outline: { background: "background", foreground: "foreground", border: "border" },
  ghost: { foreground: "foreground" },
  destructive: { background: "destructive", foreground: "destructiveForeground" },
  link: { foreground: "primary", underline: true },
} as const satisfies Record<
  string,
  { background?: ColorRole; foreground: TextColor; border?: ColorRole; underline?: boolean }
>;

export type ButtonVariant = keyof typeof variants;
export type ButtonSize = "sm" | "md" | "lg";
export type ButtonStatus = "error" | "success";

const statusIcons = { error: CircleAlert, success: Check } as const;
const iconSizes = { sm: "sm", md: "md", lg: "lg" } as const satisfies Record<ButtonSize, IconSize>;

type ButtonBaseProps = Omit<
  PressableProps,
  "children" | "style" | "size" | "pressedStyle" | "aria-label"
> & {
  /** Placement of the icon slot (icon, spinner or status icon) next to the label (default `start`). */
  iconPosition?: "start" | "end";
  /** Default `primary`. */
  variant?: ButtonVariant;
  /** Default `md`. */
  size?: ButtonSize;
  /** A spinner takes the icon slot, the label stays, presses are blocked and it is announced busy. */
  loading?: boolean;
  /** Shows an error or success icon in the icon slot and announces it. The screen clears it. */
  status?: ButtonStatus;
  /** Layout only (e.g. full width), merged last onto the root. */
  style?: StyleProp<ViewStyle>;
};

type LabelledButtonProps = {
  /** The visible text, which is also the accessible name. */
  label: string;
  /** A Lucide icon component shown in the icon slot. */
  icon?: LucideIcon;
  "aria-label"?: string;
};

type IconOnlyButtonProps = {
  label?: undefined;
  icon: LucideIcon;
  /** Required for an icon-only Button: it is the only accessible name. */
  "aria-label": string;
};

/** A Button has a `label`, or is icon-only with an `icon` and a required `aria-label`. */
export type ButtonProps = ButtonBaseProps & (LabelledButtonProps | IconOnlyButtonProps);

const useStyles = createStyles((t) => {
  const md = {
    height: t.controlHeight.md,
    borderRadius: t.radius.md,
    paddingHorizontal: t.spacing[4],
    gap: t.spacing[2],
  };
  const sm = {
    height: t.controlHeight.sm,
    paddingHorizontal: t.spacing[3],
    gap: t.scaleValue(6),
  };
  const lg = {
    height: t.controlHeight.lg,
    paddingHorizontal: t.spacing[6],
    gap: t.spacing[2],
  };
  return {
    root: {
      ...md,
      flexDirection: "row",
      alignItems: "center",
      justifyContent: "center",
      borderCurve: "continuous",
      borderWidth: t.borderWidth.default,
      borderColor: "transparent",
    },
    sm,
    md: {},
    lg,
    iconOnlySm: { width: sm.height, paddingHorizontal: 0 },
    iconOnlyMd: { width: md.height, paddingHorizontal: 0 },
    iconOnlyLg: { width: lg.height, paddingHorizontal: 0 },
    label: { ...t.type.button },
    underline: { textDecorationLine: "underline" },
    pressed: { filter: [{ brightness: 0.88 }] },
    disabled: { opacity: t.opacity.disabled },
  };
});

const useVariantStyles = createStyles((t) => ({
  primary: { backgroundColor: t.colors.primary },
  secondary: { backgroundColor: t.colors.secondary },
  outline: { backgroundColor: t.colors.background, borderColor: t.colors.border },
  ghost: { backgroundColor: "transparent" },
  destructive: { backgroundColor: t.colors.destructive },
  link: { backgroundColor: "transparent" },
}));

const iconOnlyStyle = { sm: "iconOnlySm", md: "iconOnlyMd", lg: "iconOnlyLg" } as const;

/**
 * The app's Button. Variants and Sizes map to Tokens and Style Slots; built on the Pressable
 * Primitive (pressed look from the `button.pressed` Slot, tap area extended to 48, no press
 * while disabled or loading). Precedence: `disabled` > `loading` > `status`.
 */
export function Button({
  label,
  icon,
  iconPosition = "start",
  variant = "primary",
  size = "md",
  disabled = false,
  loading = false,
  status,
  style,
  ...props
}: ButtonProps) {
  const styles = useStyles();
  const variantStyles = useVariantStyles();
  const v: { foreground: TextColor; underline?: boolean } = variants[variant];

  const isLoading = !disabled && loading;
  const shownStatus = disabled || loading ? undefined : status;
  const iconOnly = label == null;

  // Announce a new status once; the visible icon alone is not enough for screen readers.
  const announced = useRef<ButtonStatus | undefined>(undefined);
  useEffect(() => {
    if (shownStatus && shownStatus !== announced.current) {
      announce(label ? `${label}: ${shownStatus}` : shownStatus);
    }
    announced.current = shownStatus;
  }, [shownStatus, label]);

  // Known before layout, so the tap area reaches 48 from the first frame.
  const height = (size === "md" ? styles.root : styles[size]).height;

  const iconColor = v.foreground;
  const slotIcon = isLoading ? (
    // The Button itself is announced busy, so the Spinner stays silent.
    <Spinner size={iconSizes[size]} color={iconColor} aria-hidden />
  ) : shownStatus ? (
    <Icon icon={statusIcons[shownStatus]} size={iconSizes[size]} color={iconColor} />
  ) : icon ? (
    <Icon icon={icon} size={iconSizes[size]} color={iconColor} />
  ) : null;

  return (
    <Pressable
      disabled={disabled}
      loading={isLoading}
      size={{ height, width: iconOnly ? height : undefined }}
      pressedStyle={styles.pressed}
      style={[
        styles.root,
        styles[size],
        variantStyles[variant],
        iconOnly ? styles[iconOnlyStyle[size]] : null,
        disabled ? styles.disabled : null,
        style,
      ]}
      {...props}
    >
      {iconPosition === "start" ? slotIcon : null}
      {label != null ? (
        <Text
          variant="button"
          color={v.foreground}
          numberOfLines={1}
          style={[styles.label, v.underline ? styles.underline : null]}
        >
          {label}
        </Text>
      ) : null}
      {iconPosition === "end" ? slotIcon : null}
    </Pressable>
  );
}
```
