Paywall screen
ProReact Native · ExpoA complete subscription paywall: a monthly and yearly toggle with a savings badge, plan cards whose prices roll digit by digit when the period changes, a feature list, a trial note that always states the price, restore purchases and an async subscribe button.
Installation
$ npx shadcn@latest add @goodui-native-pro/paywall-screenThen run npx expo install --fix so react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/paywall-screen.
Pro screens install from the license-gated @goodui-native-pro registry. Setup: Pro access.
Usage
// app/paywall.tsx (presented as a modal route)
import { router } from "expo-router";
import Purchases from "react-native-purchases";
import { PaywallScreen } from "@/components/goodui-native/paywall-screen/paywall-screen";
import { packageFor } from "@/lib/purchases";
export default function PaywallRoute() {
return (
<PaywallScreen
appName="Stride"
heroImage={require("@/assets/paywall.jpg")}
currency="USD"
onSubscribe={(planId, period) => Purchases.purchasePackage(packageFor(planId, period))}
onSubscribed={() => router.back()}
onRestore={async () => {
const info = await Purchases.restorePurchases();
if (!info.entitlements.active.pro) throw new Error("Nothing to restore");
}}
onClose={router.back}
onOpenTerms={() => router.push("/legal/terms")}
onOpenPrivacy={() => router.push("/legal/privacy")}
/>
);
}When to use
- Selling a subscription with one to three plans and a monthly or yearly choice.
- After onboarding, or when a person taps a locked feature.
- One-time purchases or tips: use a sheet with a single button.
- Comparing many plans feature by feature: a table on the web works better.
- Checkout with a card form: this screen hands off to the store purchase sheet.
Examples
Euro prices, no trial
<PaywallScreen
currency="EUR"
locale="de-DE"
trialDays={0}
plans={[
{ id: "plus", name: "Plus", description: "Unlimited projects", price: { monthly: 4.99, yearly: 39.99 } },
{ id: "team", name: "Team", description: "Up to 10 people", price: { monthly: 12.99, yearly: 119 }, badge: "Best value" },
]}
onSubscribe={buy}
/>API reference
| Prop | Type | Default |
|---|---|---|
| appName Product name for the default copy. | string | "Stride" |
| title Headline. Same as `labels.title`. | string | — |
| subtitle One line under the headline. Same as `labels.subtitle`. | string | — |
| heroImage Full-bleed art at the top: your own photo or illustration. Without it the screen starts with the headline. | ImageSourcePropType | — |
| plans Plans as cards, in order. One or more. | PaywallPlan[] | PLANS |
| defaultPlan Plan selected at first. Defaults to the first plan with a badge, then the first plan. | string | — |
| defaultPeriod Billing period at first. Default `yearly`. | "monthly" | "yearly" | "yearly" |
| currency ISO 4217 currency for every price. Default USD. | string | "USD" |
| locale BCP 47 locale for price formatting. Defaults to the device locale. | string | — |
| features What the subscription unlocks, one short line each. | string[] | FEATURES |
| featuresTitle Heading over the feature list. Default "Everything in {appName} Pro". Same as `labels.featuresTitle`. | string | — |
| trialDays Free trial length in days. `0` means no trial: the button and the note change to a plain subscription. Default 7. | number | 7 |
| savings Badge on the yearly option. Defaults to the largest saving across plans ("Save 50%"); `null` hides it. | string | null | — |
| onSubscribe The subscribe button. Start the purchase here (RevenueCat, StoreKit, Play Billing) and return its promise: the button gathers into a spinner, then opens into a check, or shakes with "Try again" when it rejects. | (planId: string, period: BillingPeriod) => unknown | — |
| onSubscribed Called about 900 ms after `onSubscribe` resolves, once the check has landed. Close the paywall here. | (planId: string, period: BillingPeriod) => void | — |
| onRestore Restore purchases. Return a promise; reject when nothing was found to show "Nothing to restore". | () => unknown | — |
| onClose Close button at the top. Hidden when omitted. | () => void | — |
| onOpenTerms Terms link in the footer. Hidden when omitted. | () => void | — |
| onOpenPrivacy Privacy link in the footer. Hidden when omitted. | () => void | — |
| labels Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide. | Partial<PaywallScreenLabels> | — |
| style Style for the outer container. | StyleProp<ViewStyle> | — |
| testID Identifier for end-to-end tests. | string | — |
Motion and haptics
- Switching monthly and yearly rolls every price digit by digit, up or down with the change, and the per-month or per-year line under it rolls too.
- Selecting a plan springs the accent border and the radio dot in on the morph spring; cards sink to 98 % on touch down.
- Subscribe gathers into a spinner, then opens into 'Welcome to {appName} Pro' or shakes with 'Try again'.
- Reduced motion: prices change in place, the selection fades, no press scale or shake.
- Haptic: select on the billing toggle (from segmented-control)
- Haptic: tap, success and error from Subscribe and Restore purchases
- Prices are rolling-number strips on the UI thread; switching the period re-renders only the cards and the note.
- The hero is one Image; pass an optimized local asset.
Accessibility
- Plans are a radio group named 'Plan'; each card is a radio with its checked state. Native screen readers hear name, price per period, description and badge; the web reads the card's visible text.
- The billing toggle is a segmented control named 'Billing period' (from segmented-control). Prices read as their full formatted value (from rolling-number).
- The trial note is a polite live region on Android and the web, so a new price is announced there when the plan or period changes.
- Subscribe and Restore purchases are buttons that report busy and announce their result; Close is a labelled icon button.
Notes for AI
- Prices are numbers in currency units; pass currency (ISO 4217) and optionally locale. Do not pass formatted strings.
- Return the store purchase promise from onSubscribe; navigate away in onSubscribed. Return a promise from onRestore and reject when nothing was restored.
- The trial note is built from trialDays and the selected price; trialDays={0} switches the button to 'Subscribe for {price}'.
- savings defaults to the best yearly saving across plans; pass a string to override or null to hide it.
- heroImage comes in through props; the component bundles no images. Close, Terms, Privacy and Restore render only when their callbacks are passed.
Related
Two to five views of the same thing with an elastic highlight that stretches toward the new segment, can be dragged, and ticks at every boundary. Includes a billing toggle.
A number whose digits roll in the direction of change, with digits that open and close in place and currency signs that glide instead of jumping.
A press-responsive button with haptics that runs async actions on its own: it gathers into a spinner, then opens into a success or error state.
A count, dot or status badge that springs in when it appears, pops when its value changes, caps at 99+ and shrinks away at zero; BadgeAnchor pins it to an icon or avatar.