Button
Button with Variants (primary, secondary, outline, ghost, destructive, link), Sizes, an icon slot, loading and status; icon-only requires aria-label.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add buttonAlso installs: theme, pressable, text, icon, spinner, 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.
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] },
}));Button demo
Every Variant and Size, an icon, icon-only, loading, status and disabled.
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,linksize:sm,md,lgstatus: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
The Vega Style's code, exactly as add copies it into your app.
{components}/button.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>
);
}{components}/button.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.sm,
borderRadius: t.radius.sm,
paddingHorizontal: t.spacing[3],
gap: t.scaleValue(6),
};
const sm = {
height: t.scaleValue(32),
paddingHorizontal: t.scaleValue(10),
gap: t.spacing[1],
};
const lg = {
height: t.controlHeight.md,
paddingHorizontal: t.spacing[4],
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,
fontSize: t.scaleValue(14),
lineHeight: t.scaleValue(20),
},
underline: { textDecorationLine: "underline" },
pressed: { filter: [{ brightness: 0.88 }], transform: [{ scale: 0.98 }] },
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>
);
}
