Tactile pill button with press spring, ink wash and async loading.
components/ui/button/button.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Button } from "@/components/ui/button/button";
export function Example() {
return (
<Button />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Pressable, Text, ActivityIndicator, type PressableProps, type GestureResponderEvent, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
import { cn, createVariant, useAsyncPress, useHaptic, usePressAnimation, useReducedMotion, useTheme, a11yProps, AnimatedPressable, timing, type A11yProps, } from "@lumioui/core";
import { Glass } from "../glass";
/** Press depth scales inversely with surface size — a small tap target needs
* a deeper dip to read as a press; a large surface needs less travel. */
const PRESSED_SCALE = { sm: 0.94, md: 0.96, lg: 0.97, icon: 0.92 } as const;
const buttonVariants = createVariant({
base: "flex-row items-center justify-center overflow-hidden",
variants: {
variant: {
// SwiftUI .borderedProminent — the primary action
default: "bg-primary",
// Destructive action — red filled
destructive: "bg-destructive",
// SwiftUI .bordered — outline with border
outline: "border border-border bg-transparent",
// SwiftUI .tinted — subtle primary tint background
tinted: "bg-primary/15",
// Secondary — neutral background
secondary: "bg-secondary",
// SwiftUI .plain — no background, just text
ghost: "bg-transparent",
link: "bg-transparent",
// Liquid glass — the material supplies the surface
glass: "bg-transparent",
},
size: {
// Gaps scale with size — sm felt loose with gap-2; lg felt cramped.
// md/h-11 = 44pt iOS touch target; sm keeps visual size but gets hitSlop below.
sm: "h-8 gap-1.5 px-3.5",
md: "h-11 gap-2 px-5",
lg: "h-[52px] gap-2 px-7",
icon: "h-11 w-11 gap-0 px-0",
},
radius: {
pill: "rounded-full",
xl: "rounded-3xl",
lg: "rounded-2xl",
md: "rounded-xl",
},
},
defaultVariants: { variant: "default", size: "md", radius: "pill" },
});
/** `tone` restyles the filled "default" variant for contexts where the accent
* pill is wrong — `inverse` is the boutique near-black CTA, `surface` a
* bordered card-colored button. Only meaningful with variant="default". */
const toneContainer = {
accent: "",
inverse: "bg-foreground",
surface: "bg-card border border-border",
} as const;
const toneText = {
accent: "",
inverse: "text-background",
surface: "text-foreground",
} as const;
/** Text-like children (string/number/array of them) belong inside the styled
* label Text — variant colors only reach them there. Elements (icons, views)
* must bypass it: they aren't text and RN can't nest views in Text. */
const isTextualChildren = (node: React.ReactNode): boolean =>
node == null ||
typeof node === "boolean" ||
typeof node === "string" ||
typeof node === "number" ||
(Array.isArray(node) && node.every(isTextualChildren));
const buttonTextVariants = createVariant({
base: "text-base font-semibold",
variants: {
variant: {
default: "text-primary-foreground",
destructive: "text-destructive-foreground",
outline: "text-foreground",
tinted: "text-primary",
secondary: "text-secondary-foreground",
ghost: "text-foreground",
link: "text-primary underline",
glass: "text-foreground",
},
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
icon: "text-base",
},
},
defaultVariants: { variant: "default", size: "md" },
});
export interface ButtonProps extends Omit<PressableProps, "style" | "children" | "onPress">, A11yProps {
/** Press handler — may return a Promise. With asyncTracking (default) the
* button enters the loading state until the promise settles. Declared as
* a union of signatures rather than `void | Promise<unknown>` so `void`
* keeps its leniency — handlers returning other values (e.g. a timeout
* id) still typecheck. */
onPress?:
| ((e: GestureResponderEvent) => void)
| ((e: GestureResponderEvent) => Promise<unknown>)
| null;
/** Visual style — "glass" is the liquid-glass material button. */
variant?: "default" | "destructive" | "outline" | "tinted" | "secondary" | "ghost" | "link" | "glass";
/** Fill voice for the default variant — `inverse` is the near-black CTA,
* `surface` a bordered card-colored button. Default "accent". */
tone?: "accent" | "inverse" | "surface";
/** Corner radius. Default "pill". */
radius?: "pill" | "xl" | "lg" | "md";
/** Size preset. */
size?: "sm" | "md" | "lg" | "icon";
/** Crossfade to a spinner and block presses while true. */
loading?: boolean;
/** Auto-track async onPress — when onPress returns a Promise, the button
* automatically enters loading state until it resolves. Default true. */
asyncTracking?: boolean;
/** Haptic feedback on press. Default "light"; false to disable. */
haptic?: "light" | "medium" | "heavy" | false;
/** Override the press-dip scale (1 = none). ButtonGroup sets this to 1 on
* attached children — a scaled segment opens a gap in the weld. */
pressedScale?: number;
/** Extra NativeWind classes for the container. */
className?: string;
/** Extra classes for the label. */
textClassName?: string;
/** Icon rendered before the label. */
icon?: React.ReactNode;
children?: React.ReactNode;
}
/**
* Button — tactile, spring-animated, haptic-enabled pill.
* Craft details: heavy spring scale on press, a light overlay that fades in
* while held (physical "ink"), and a crossfade into the loading spinner so
* the button never snaps or resizes. Faces stay flat — depth comes from the
* press, not from gloss or drop shadows.
*/
export const Button = forwardRef<React.ElementRef<typeof Pressable>, ButtonProps>(
function Button(
{
variant = "default",
size = "md",
tone = "accent",
radius = "pill",
loading = false,
asyncTracking = true,
haptic = "light",
pressedScale,
className,
textClassName,
icon,
children,
disabled,
onPress,
onPressIn,
onPressOut,
accessibilityLabel,
accessibilityHint,
accessibilityRole,
accessibilityState,
...props
},
ref
) {
const haptics = useHaptic();
const reduceMotion = useReducedMotion();
const { colors } = useTheme();
// Auto-track async onPress promises
const asyncPress = useAsyncPress(
asyncTracking ? (onPress ?? undefined) : undefined,
{
haptic: haptic ? () => haptics[haptic]() : undefined,
}
);
const isLoading = loading || asyncPress.loading;
const isDisabled = disabled || isLoading;
// Link buttons are text-only — a scale dip on bare text looks odd, so
// they fade instead (iOS `.plain` idiom). Others dip by size.
// `disabled` is folded into the animated style — an opacity className on
// the same element is overridden by the hook's inline style on web.
const press = usePressAnimation(
variant === "link"
? { pressedScale: 1, pressedOpacity: 0.6, disabled: isDisabled }
: { pressedScale: pressedScale ?? PRESSED_SCALE[size], disabled: isDisabled }
);
// Held-state ink overlay — a faint wash that confirms contact instantly.
const ink = useSharedValue(0);
// Loading crossfade — label out, spinner in, no layout snap.
const loadingAnim = useSharedValue(isLoading ? 1 : 0);
React.useEffect(() => {
loadingAnim.value = withTiming(isLoading ? 1 : 0, timing.fast);
}, [isLoading, loadingAnim]);
const handlePress = (e: GestureResponderEvent) => {
if (asyncTracking) {
asyncPress.handlePress(e);
} else {
if (haptic) haptics[haptic]();
onPress?.(e);
}
};
const handlePressIn = (e: GestureResponderEvent) => {
press.onPressIn?.();
if (!reduceMotion) ink.value = withTiming(1, timing.instant);
onPressIn?.(e);
};
const handlePressOut = (e: GestureResponderEvent) => {
press.onPressOut?.();
ink.value = withTiming(0, timing.normal);
onPressOut?.(e);
};
const inkStyle = useAnimatedStyle(() => ({ opacity: ink.value * 0.12 }));
const labelStyle = useAnimatedStyle(() => ({
opacity: 1 - loadingAnim.value,
// Reduced motion keeps the crossfade, drops the scale travel.
transform: [{ scale: reduceMotion ? 1 : 1 - loadingAnim.value * 0.06 }],
}));
const spinnerStyle = useAnimatedStyle(() => ({
opacity: loadingAnim.value,
transform: [{ scale: reduceMotion ? 1 : 0.7 + loadingAnim.value * 0.3 }],
}));
const filled = variant === "default" || variant === "destructive";
// Ink wash from theme tokens: primaryForeground (white) on filled,
// foreground (scheme-aware) on translucent surfaces.
const inkColor = filled ? colors.primaryForeground : colors.foreground;
const spinnerColor =
variant === "default"
? tone === "inverse"
? colors.background
: tone === "surface"
? colors.foreground
: colors.primaryForeground
: variant === "destructive"
? colors.destructiveForeground
: variant === "tinted" || variant === "link"
? colors.primary
: colors.foreground;
return (
<AnimatedPressable
ref={ref}
disabled={isDisabled}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
// sm is 32px visually — expand the hit area to 44pt so it doesn't
// feel broken. md/lg/icon are exactly 44pt+; hitSlop 2 adds a small
// buffer for edge misses without overlapping neighbors.
hitSlop={size === "sm" ? 6 : 2}
style={press.animatedStyle}
className={cn(
buttonVariants({ variant, size, radius }),
variant === "default" && toneContainer[tone],
isDisabled && "opacity-50",
className
)}
{...a11yProps(
{ accessibilityLabel, accessibilityHint, accessibilityRole, accessibilityState },
"button"
)}
accessibilityState={{ disabled: isDisabled, busy: isLoading, ...accessibilityState }}
// accessibilityState isn't forwarded to the DOM on web — mirror
// disabled/busy with explicit aria-* like Switch/Toggle do.
aria-disabled={isDisabled}
aria-busy={isLoading}
{...props}
>
{variant === "glass" ? (
<Glass
effect="regular"
sheen={0.55}
bend={0.25}
interactive
className="absolute inset-0"
style={{ borderRadius: radius === "pill" ? 999 : radius === "xl" ? 24 : radius === "lg" ? 16 : 12 }}
pointerEvents="none"
/>
) : null}
{/* Ink overlay — held state */}
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: inkColor,
},
inkStyle,
]}
/>
{/* Label layer — fades/shrinks out when loading starts */}
<Animated.View
style={[{ flexDirection: "row", alignItems: "center", gap: 8 }, labelStyle]}
>
{icon}
{isTextualChildren(children) ? (
<Text
className={cn(
buttonTextVariants({ variant, size }),
variant === "default" && toneText[tone],
textClassName
)}
>
{children}
</Text>
) : (
children
)}
</Animated.View>
{/* Spinner layer — scales in over the label */}
<Animated.View
pointerEvents="none"
accessibilityElementsHidden
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
},
spinnerStyle,
]}
>
<ActivityIndicator color={spinnerColor} size="small" />
</Animated.View>
</AnimatedPressable>
);
}
);
Button.displayName = "Button";
export { buttonVariants, buttonTextVariants };
| onPress? | | ((e: GestureResponderEvent) => void) | ((e: GestureResponderEvent) => Promise<unknown>) | null | Press handler — may return a Promise. With asyncTracking (default) the button enters the loading state until the promise settles. Declared as a union of signatures rather than `void | Promise<unknown>` so `void` keeps its leniency — handlers returning other values (e.g. a timeout id) still typecheck. | |
| variant? | "default" | "destructive" | "outline" | "tinted" | "secondary" | "ghost" | "link" | "glass" | Visual style — "glass" is the liquid-glass material button. | |
| tone? | "accent" | "inverse" | "surface" | Fill voice for the default variant — `inverse` is the near-black CTA, `surface` a bordered card-colored button. Default "accent". | |
| radius? | "pill" | "xl" | "lg" | "md" | Corner radius. Default "pill". | |
| size? | "sm" | "md" | "lg" | "icon" | Size preset. | |
| loading? | boolean | Crossfade to a spinner and block presses while true. | |
| asyncTracking? | boolean | Auto-track async onPress — when onPress returns a Promise, the button automatically enters loading state until it resolves. Default true. | |
| haptic? | "light" | "medium" | "heavy" | false | Haptic feedback on press. Default "light"; false to disable. | |
| pressedScale? | number | Override the press-dip scale (1 = none). ButtonGroup sets this to 1 on attached children — a scaled segment opens a gap in the weld. | |
| className? | string | Extra NativeWind classes for the container. | |
| textClassName? | string | Extra classes for the label. | |
| icon? | React.ReactNode | Icon rendered before the label. | |
| children? | React.ReactNode |
Tactile pill button with press spring, ink wash and async loading.
import { ButtonGlass } from "@/components/ui/button/variants/glass";
export function Example() {
return (
<ButtonGlass />
);
} import * as React from "react";
import { View, Text } from "react-native";
import { Button } from "../button";
import { Icon } from "../../icon/icon";
import { Ambient } from "../../glass/ambient";
/** ButtonGlass — glass CTAs over a neon night scene. */
export function ButtonGlass() {
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="btn" />
<View className="absolute inset-x-5 bottom-7 gap-3">
<Button variant="glass" size="lg" icon={<Icon name="play" size="sm" color="#fff" />}>
Start session
</Button>
<View className="flex-row gap-2">
<Button variant="glass" size="sm" className="flex-1">Preview</Button>
<Button variant="glass" size="icon" icon={<Icon name="share" size="sm" color="#fff" />} />
</View>
</View>
<View className="absolute left-5 top-5">
<Text className="text-[10px] font-semibold uppercase tracking-widest text-white/90">
GLASS ACTIONS
</Text>
</View>
</View>
);
}
Tactile pill button with press spring, ink wash and async loading.
import { ButtonSizes } from "@/components/ui/button/variants/sizes";
export function Example() {
return (
<ButtonSizes />
);
} import * as React from "react";
import { View } from "react-native";
import { Button } from "../button";
import { Glass } from "../../glass/glass";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
/** ButtonSizes — size scale plus loading and disabled states on glass. */
export function ButtonSizes() {
// Async tracking: returning a promise auto-enters loading until it resolves.
const payNow = async () => {
await new Promise((r) => setTimeout(r, 1400));
};
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-4 rounded-2xl p-4">
<View className="gap-2">
<Text variant="small" className="font-semibold">Sizes</Text>
<View className="flex-row flex-wrap items-center gap-2.5">
<Button size="sm">Reorder</Button>
<Button size="md">View Cart</Button>
<Button size="lg">Checkout</Button>
<Button
size="icon"
icon={<Icon name="shopping-cart" size="sm" color="primaryForeground" />}
accessibilityLabel="Add to cart"
/>
</View>
</View>
<View className="gap-2 border-t border-border pt-3">
<Text variant="small" className="font-semibold">States</Text>
<View className="flex-row flex-wrap items-center gap-2.5">
<Button onPress={payNow}>Buy Now</Button>
<Button loading>Placing order…</Button>
<Button disabled>Sold Out</Button>
<Button variant="tinted" icon={<Icon name="arrow-right" size="sm" />}>Continue</Button>
</View>
</View>
</Glass>
</View>
);
}
Tactile pill button with press spring, ink wash and async loading.
import { ButtonStyles } from "@/components/ui/button/variants/styles";
export function Example() {
return (
<ButtonStyles />
);
} import * as React from "react";
import { View } from "react-native";
import { Button } from "../button";
import { Glass } from "../../glass/glass";
import { Text } from "../../text/text";
/** ButtonStyles — each voice gets its own glass surface so the treatments read
* as separate decisions, not a stack of every button at once. */
export function ButtonStyles() {
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-2.5 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Solid voices</Text>
<Button className="w-full">Pay $4,280.50</Button>
<Button tone="inverse" className="w-full">Buy Now · $60.00</Button>
<Button tone="surface" className="w-full">View Receipt</Button>
</Glass>
<Glass interactive curvature="edge" className="w-full gap-2.5 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Quiet voices</Text>
<Button variant="tinted" className="w-full">Track Order #4821</Button>
<Button variant="outline" className="w-full">Add Promo Code</Button>
<Button variant="ghost" className="w-full">Save for Later</Button>
</Glass>
<Glass interactive curvature="edge" className="w-full gap-2.5 rounded-2xl p-4">
<Text variant="small" className="font-semibold">Text + destructive</Text>
<Button variant="link" className="w-full">Terms of Service</Button>
<Button variant="destructive" className="w-full">Cancel Reservation</Button>
</Glass>
</View>
);
}