Animated subscription paywall bottom sheet with plan options and haptic feedback
components/ui/paywall-sheet/paywall-sheet.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-safe-area-context@*expo-haptics@*| visible | boolean | |
| onClose | () => void | |
| onSubscribe | (plan: PaywallPlan) => void | Promise<void> | |
| selectedPlan? | PaywallPlan | Controlled selected plan. |
| defaultSelectedPlan? | PaywallPlan | Uncontrolled initial plan. Default "yearly". |
| onPlanChange? | (plan: PaywallPlan) => void | |
| monthlyPrice? | string | |
| yearlyPrice? | string | |
| purchasing? | PaywallPlan | null | Which plan is mid-purchase — that CTA shows a spinner. Null when idle. |
| onRestore? | () => void | Promise<void> | Restore-purchases handler — renders the restore row when provided. |
| restoring? | boolean | Restore in flight — the restore row shows a spinner. Default false. |
| features? | string[] | Bulleted feature list under the subtitle. |
| title? | string | |
| subtitle? | string | |
| className? | string |
Animated subscription paywall bottom sheet with plan options and haptic feedback
import { PaywallSheet } from "@/components/ui/paywall-sheet/paywall-sheet";
export function Example() {
return (
<PaywallSheet />
);
} import * as React from "react";
import { forwardRef, useState } from "react";
import { View, Text, Modal, Pressable, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withSpring,
withTiming,
runOnJS,
Easing,
} from "react-native-reanimated";
import { cn, useHaptic, spring, timing, ease, useReducedMotion, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Button } from "@lumioui/ui/button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export type PaywallPlan = "monthly" | "yearly";
/**
* PaywallSheet — PRO component. A subscription paywall bottom sheet:
* animated entrance, selectable plan options, purchase + restore actions
* with haptic feedback.
* Requires a dev build on Expo (uses Reanimated + native modules).
*/
export interface PaywallSheetProps extends Omit<ViewProps, "className"> {
visible: boolean;
onClose: () => void;
onSubscribe: (plan: PaywallPlan) => void | Promise<void>;
/** Controlled selected plan. */
selectedPlan?: PaywallPlan;
/** Uncontrolled initial plan. Default "yearly". */
defaultSelectedPlan?: PaywallPlan;
onPlanChange?: (plan: PaywallPlan) => void;
monthlyPrice?: string;
yearlyPrice?: string;
/** Which plan is mid-purchase — that CTA shows a spinner. Null when idle. */
purchasing?: PaywallPlan | null;
/** Restore-purchases handler — renders the restore row when provided. */
onRestore?: () => void | Promise<void>;
/** Restore in flight — the restore row shows a spinner. Default false. */
restoring?: boolean;
/** Bulleted feature list under the subtitle. */
features?: string[];
title?: string;
subtitle?: string;
className?: string;
}
/** Sheet exit — timing.fast on the accelerating ease.in curve so the sheet
* leaves faster than the spring.surface entrance settles. */
const SHEET_EXIT = { ...timing.fast, easing: Easing.bezier(...ease.in) } as const;
/** Backdrop exit — same fast band so the dim clears with the sheet. */
const BACKDROP_EXIT = timing.fast;
/** Content stagger — 70ms beats so the eye reads title → plans → CTA as the
* sheet lands, matching the beui "explain space, then introduce content"
* choreography. Bespoke because the token durations are per-element, not
* orchestration delays. */
const STAGGER_MS = 70;
const STAGGER_RISE = 14;
/** Entrance wrapper — children fade up into place `delay`ms after mount.
* Reduced motion keeps the fade, drops the rise. */
function StaggerItem({ delay, children }: { delay: number; children: React.ReactNode }) {
const reduce = useReducedMotion();
const progress = useSharedValue(0);
React.useEffect(() => {
progress.value = reduce ? 1 : withDelay(delay, withTiming(1, timing.normal));
}, [delay, progress, reduce]);
const style = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ translateY: (1 - progress.value) * STAGGER_RISE }],
}));
return <Animated.View style={style}>{children}</Animated.View>;
}
interface PlanOptionProps {
id: PaywallPlan;
label: string;
sub: string;
selected: boolean;
onSelect: (id: PaywallPlan) => void;
}
/** Plan row — own component so each option gets press physics and the
* radio dot pops in with a spring when selected. */
function PlanOption({ id, label, sub, selected, onSelect }: PlanOptionProps) {
const reduce = useReducedMotion();
const press = usePressAnimation({ pressedScale: 0.98 });
const dot = useSharedValue(selected ? 1 : 0);
React.useEffect(() => {
dot.value = reduce ? (selected ? 1 : 0) : withSpring(selected ? 1 : 0, spring.bouncy);
}, [selected, dot, reduce]);
const dotStyle = useAnimatedStyle(() => ({
transform: [{ scale: dot.value }],
opacity: Math.min(1, dot.value * 2),
}));
return (
<AnimatedPressable
onPress={() => onSelect(id)}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
className={cn(
"flex-row items-center gap-3 rounded-xl border p-4",
selected ? "border-primary bg-accent" : "border-border bg-card"
)}
accessibilityRole="radio"
accessibilityState={{ selected }}
// RNW drops accessibilityState — the radio role must report it on web.
aria-checked={selected}
accessibilityLabel={`${label}. ${sub}`}
hitSlop={2}
>
<View
className={cn(
"h-5 w-5 items-center justify-center rounded-full border-2",
selected ? "border-primary" : "border-input"
)}
accessible={false}
>
<Animated.View style={dotStyle} className="h-2.5 w-2.5 rounded-full bg-primary" />
</View>
<View className="flex-1 gap-0.5">
<Text className="text-base font-semibold leading-tight text-card-foreground">
{label}
</Text>
<Text className="text-xs leading-4 text-muted-foreground">{sub}</Text>
</View>
</AnimatedPressable>
);
}
export const PaywallSheet = forwardRef<React.ElementRef<typeof View>, PaywallSheetProps>(
function PaywallSheet(
{
visible,
onClose,
onSubscribe,
selectedPlan,
defaultSelectedPlan = "yearly",
onPlanChange,
monthlyPrice = "$19/mo",
yearlyPrice = "$149/yr",
purchasing = null,
onRestore,
restoring = false,
features,
title = "Go Pro",
subtitle = "Unlock every component, screen template, and future update.",
className,
...props
},
ref
) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const maybeLaterPress = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.7 });
const sheet = useSharedValue(0);
const backdrop = useSharedValue(0);
const [rendered, setRendered] = useState(visible);
const [internalPlan, setInternalPlan] = useState<PaywallPlan>(defaultSelectedPlan);
const plan = selectedPlan ?? internalPlan;
const setPlan = (next: PaywallPlan) => {
if (next === plan) return;
haptics.selection();
if (selectedPlan === undefined) setInternalPlan(next);
onPlanChange?.(next);
};
// Deferred unmount — `rendered` keeps the Modal mounted through the
// exit; it drops only on the animation's completion callback (a fixed
// setTimeout could fire before or after the real exit ends).
const wasVisible = React.useRef(false);
React.useEffect(() => {
if (visible && !wasVisible.current) {
setRendered(true);
backdrop.value = withTiming(1, timing.fast);
sheet.value = reduce ? 1 : withSpring(1, spring.surface);
} else if (!visible && wasVisible.current) {
// Exits run faster than the entrance.
if (reduce) {
sheet.value = 0;
backdrop.value = 0;
setRendered(false);
} else {
sheet.value = withTiming(0, SHEET_EXIT);
backdrop.value = withTiming(0, BACKDROP_EXIT, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
}
}
wasVisible.current = visible;
}, [visible, sheet, backdrop, reduce]);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: (1 - sheet.value) * 320 }],
opacity: sheet.value,
}));
const backdropStyle = useAnimatedStyle(() => ({ opacity: backdrop.value }));
if (!rendered) return null;
const plans: { id: PaywallPlan; label: string; sub: string }[] = [
{ id: "yearly", label: `Yearly — ${yearlyPrice}`, sub: "Save 35% · billed once a year" },
{ id: "monthly", label: `Monthly — ${monthlyPrice}`, sub: "Billed monthly · cancel anytime" },
];
return (
<Modal visible transparent animationType="none" onRequestClose={onClose}>
<View style={{ width: "100%", height: "100%" }}>
<Animated.View className="bg-overlay" style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, backdropStyle]}>
<Pressable
style={{ width: "100%", height: "100%" }}
onPress={() => {
haptics.selection();
onClose();
}}
accessibilityLabel="Close paywall"
accessibilityRole="button"
/>
</Animated.View>
<Animated.View
ref={ref}
style={[{ paddingBottom: Math.max(40, insets.bottom + 16) }, sheetStyle]}
className={cn("absolute bottom-0 w-full max-w-[480px] self-center rounded-t-2xl bg-card p-5", className)}
accessibilityViewIsModal
// RN's accessibilityRole enum has no "dialog" — `role` carries
// the ARIA dialog semantics instead (matches Dialog/Popover).
role="dialog"
aria-modal
accessibilityLabel={title}
{...props}
>
<View className="mb-4 h-1 w-10 self-center rounded-full bg-border" accessible={false} />
<StaggerItem delay={STAGGER_MS}>
<Text className="text-2xl font-bold leading-tight text-card-foreground" accessibilityRole="header">
{title}
</Text>
<Text className="mt-1 text-sm leading-5 text-muted-foreground">
{subtitle}
</Text>
</StaggerItem>
{features?.length ? (
<StaggerItem delay={STAGGER_MS * 2}>
<View className="mt-4 gap-2">
{features.map((feature) => (
<View key={feature} className="flex-row items-center gap-2">
<View className="h-1.5 w-1.5 rounded-full bg-primary" accessible={false} />
<Text className="flex-1 text-sm leading-5 text-card-foreground">{feature}</Text>
</View>
))}
</View>
</StaggerItem>
) : null}
<StaggerItem delay={STAGGER_MS * 3}>
<View
className="mt-4 gap-2"
accessibilityRole="radiogroup"
accessibilityLabel="Subscription plans"
>
{plans.map((option) => (
<PlanOption
key={option.id}
id={option.id}
label={option.label}
sub={option.sub}
selected={plan === option.id}
onSelect={setPlan}
/>
))}
</View>
</StaggerItem>
<StaggerItem delay={STAGGER_MS * 4}>
<Button
size="lg"
haptic="medium"
loading={purchasing === plan}
disabled={purchasing !== null}
onPress={() => {
haptics.success();
onSubscribe(plan);
}}
className="mt-4"
accessibilityLabel={`Subscribe to ${plan} plan`}
>
Continue with {plan === "yearly" ? "Yearly" : "Monthly"}
</Button>
{onRestore ? (
<Button
variant="ghost"
size="lg"
haptic="light"
loading={restoring}
onPress={() => {
haptics.light();
onRestore();
}}
>
Restore purchases
</Button>
) : null}
<AnimatedPressable
onPress={() => {
haptics.selection();
onClose();
}}
onPressIn={maybeLaterPress.onPressIn}
onPressOut={maybeLaterPress.onPressOut}
style={maybeLaterPress.animatedStyle}
className="mt-1 min-h-[44px] items-center justify-center"
accessibilityRole="button"
accessibilityLabel="Dismiss without subscribing"
hitSlop={4}
>
<Text className="text-sm text-muted-foreground">Maybe later</Text>
</AnimatedPressable>
</StaggerItem>
</Animated.View>
</View>
</Modal>
);
}
);
PaywallSheet.displayName = "PaywallSheet";