Paywall screen

ProReact Native · Expo

A 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.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add @goodui-native-pro/paywall-screen

Then 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

Use it for
  • Selling a subscription with one to three plans and a monthly or yearly choice.
  • After onboarding, or when a person taps a locked feature.
Reach for something else
  • 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

PropTypeDefault
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.
number7
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.