Brand-gradient CTA pill with spring press, ink wash, and loading crossfade — flat face, no gloss — the Signature checkout/upgrade button.
components/ui/gradient-button/gradient-button.tsx@lumioui/core@latestreact-native-reanimated@*react-native-svg@*| from? | string | Gradient start color (hex). Left/top stop. |
| to? | string | Gradient end color (hex). Right/bottom stop. |
| angle? | "horizontal" | "vertical" | Gradient direction. |
| size? | "sm" | "md" | "lg" | Size preset. |
| radius? | "pill" | "xl" | "lg" | "md" | Corner radius. Default "pill". |
| loading? | boolean | Crossfade to a spinner and block presses while true. |
| asyncTracking? | boolean | Auto-track async onPress — when onPress returns a Promise, the button enters the loading state until it resolves. Default true. |
| haptic? | "light" | "medium" | "heavy" | false | Haptic feedback on press. Default "light"; false to disable. |
| icon? | React.ReactNode | Icon rendered before the label. |
| textClassName? | string | Extra classes for the label — default is white for the opaque fill. |
| className? | string | Extra classes for the container. |
| onPress? | | ((e: GestureResponderEvent) => void) | ((e: GestureResponderEvent) => Promise<unknown>) | null | |
| children? | React.ReactNode |
Brand-gradient CTA pill with spring press, ink wash, and loading crossfade — flat face, no gloss — the Signature checkout/upgrade button.
import { GradientButton } from "@/components/ui/gradient-button/gradient-button";
export function Example() {
return (
<GradientButton />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Text, ActivityIndicator, Pressable, type PressableProps, type GestureResponderEvent, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
import Svg, { Defs, LinearGradient, Stop, Rect } from "react-native-svg";
import { cn, createVariant, useAsyncPress, useHaptic, usePressAnimation, useReducedMotion, a11yProps, AnimatedPressable, timing, type A11yProps, } from "@lumioui/core";
const PRESSED_SCALE = { sm: 0.94, md: 0.96, lg: 0.97 } as const;
const sizeVariants = createVariant({
base: "flex-row items-center justify-center overflow-hidden",
variants: {
size: {
// md/h-11 = 44pt iOS touch target; sm 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",
},
radius: {
pill: "rounded-full",
xl: "rounded-3xl",
lg: "rounded-2xl",
md: "rounded-xl",
},
},
defaultVariants: { size: "md", radius: "pill" },
});
const textVariants = createVariant({
// White label is the default because the fill is an opaque brand gradient
// — same art-direction contract as StoryChrome's raw-hex surfaces.
base: "font-semibold text-white",
variants: {
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
defaultVariants: { size: "md" },
});
export interface GradientButtonProps
extends Omit<PressableProps, "style" | "children" | "onPress">,
A11yProps {
/** Gradient start color (hex). Left/top stop. */
from?: string;
/** Gradient end color (hex). Right/bottom stop. */
to?: string;
/** Gradient direction. */
angle?: "horizontal" | "vertical";
/** Size preset. */
size?: "sm" | "md" | "lg";
/** Corner radius. Default "pill". */
radius?: "pill" | "xl" | "lg" | "md";
/** Crossfade to a spinner and block presses while true. */
loading?: boolean;
/** Auto-track async onPress — when onPress returns a Promise, the button
* enters the loading state until it resolves. Default true. */
asyncTracking?: boolean;
/** Haptic feedback on press. Default "light"; false to disable. */
haptic?: "light" | "medium" | "heavy" | false;
/** Icon rendered before the label. */
icon?: React.ReactNode;
/** Extra classes for the label — default is white for the opaque fill. */
textClassName?: string;
/** Extra classes for the container. */
className?: string;
onPress?:
| ((e: GestureResponderEvent) => void)
| ((e: GestureResponderEvent) => Promise<unknown>)
| null;
children?: React.ReactNode;
}
/**
* GradientButton — brand-gradient CTA with the same craft as Button:
* spring press dip, ink wash while held, spinner crossfade — flat face.
* `from`/`to` are raw hex art direction, identical in both schemes.
*/
export const GradientButton = forwardRef<
React.ElementRef<typeof Pressable>,
GradientButtonProps
>(function GradientButton(
{
from = "#4169E1",
to = "#7C5CFC",
angle = "horizontal",
size = "md",
radius = "pill",
loading = false,
asyncTracking = true,
haptic = "light",
icon,
textClassName,
className,
children,
disabled,
onPress,
onPressIn,
onPressOut,
accessibilityLabel,
accessibilityHint,
accessibilityRole,
accessibilityState,
...props
},
ref
) {
const haptics = useHaptic();
const reduceMotion = useReducedMotion();
const asyncPress = useAsyncPress(
asyncTracking ? (onPress ?? undefined) : undefined,
{ haptic: haptic ? () => haptics[haptic]() : undefined }
);
const isLoading = loading || asyncPress.loading;
const isDisabled = disabled || isLoading;
const press = usePressAnimation({
pressedScale: PRESSED_SCALE[size],
disabled: isDisabled,
});
const ink = useSharedValue(0);
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);
};
// Held-state ink — darkens the gradient so the touch reads instantly.
const inkStyle = useAnimatedStyle(() => ({ opacity: ink.value * 0.18 }));
const labelStyle = useAnimatedStyle(() => ({
opacity: 1 - loadingAnim.value,
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 gradId = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const horizontal = angle === "horizontal";
return (
<AnimatedPressable
ref={ref}
disabled={isDisabled}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
hitSlop={size === "sm" ? 6 : 2}
style={press.animatedStyle}
className={cn(sizeVariants({ size, radius }), isDisabled && "opacity-50", className)}
{...a11yProps(
{ accessibilityLabel, accessibilityHint, accessibilityRole, accessibilityState },
"button"
)}
accessibilityState={{ disabled: isDisabled, busy: isLoading, ...accessibilityState }}
aria-disabled={isDisabled}
aria-busy={isLoading}
{...props}
>
{/* Base gradient — full-bleed rect clipped by the pill's rounded-full. */}
<Svg
width="100%"
height="100%"
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
accessible={false}
>
<Defs>
<LinearGradient
id={`${gradId}-fill`}
x1="0"
y1="0"
x2={horizontal ? 1 : 0}
y2={horizontal ? 0 : 1}
>
<Stop offset={0} stopColor={from} />
<Stop offset={1} stopColor={to} />
</LinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradId}-fill)`} />
</Svg>
{/* Ink overlay — held state */}
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#000",
},
inkStyle,
]}
/>
<Animated.View
style={labelStyle}
className="flex-row items-center justify-center gap-2"
>
{icon}
<Text className={cn(textVariants({ size }), textClassName)}>{children}</Text>
</Animated.View>
<Animated.View
pointerEvents="none"
style={[{ position: "absolute", alignSelf: "center" }, spinnerStyle]}
>
<ActivityIndicator color="#fff" />
</Animated.View>
</AnimatedPressable>
);
});
GradientButton.displayName = "GradientButton";