Freemium products live or die by their upgrade flow. Every month, a small percentage of free users encounter a premium feature, see an upgrade prompt, and decide whether the product is worth paying for. This moment — the upgrade prompt — is the highest-leverage UX surface in the entire product. A well-designed flow converts at 2-5% of free users. A poorly designed one converts at less than 1%, often because users never even click on premium features.
This guide covers the complete upgrade workflow: when to show the prompt, what the modal should contain, how to gate features, how to display pricing, how to handle dismissal, and how to integrate with Stripe Checkout. It synthesises research from Appcues, NNGroup, Stripe, Apphud, and analyses of Slack, Spotify, Canva, Grammarly, and other leading SaaS products.## Table of Contents
- Terminology
- When to Show the Upgrade Prompt
- The Three Types of Feature Gates
- Upgrade Modal Design
- Pricing Display
- CTA Copy and Button Design
- Trust Signals and Objection Handling
- Dismiss Behaviour and Re-engagement
- Unauthenticated vs Authenticated Flows
- Stripe Checkout Integration
- React Component Architecture
- Component Specifications
- Accessibility Requirements
- Common Anti-Patterns
- References## Terminology
The industry uses several overlapping terms for this pattern. They are not interchangeable: each carries connotations that shape how teams think about the design, and choosing the wrong term leads to building the wrong thing.
| Term | Meaning | Connotation |
|---|---|---|
| Paywall | Blocking access to content or features until payment | Comes from publishing. Implies a wall between the user and something they want. The mental model is denial: the user is being kept out. |
| Upsell | Convincing an existing user to buy a more expensive tier | Comes from sales. Implies the user already has something and you’re persuading them to get more. The mental model is expansion. |
| Feature gate | A conditional check that controls access to a specific capability | Comes from engineering. Neutral: it’s a mechanism, not a business strategy. A gate can be opened or closed. |
| Upgrade prompt | The UI that appears when a gate is triggered | Describes what the user sees. Neutral-to-positive framing — “upgrade” implies moving up, not being blocked. |
| Freemium conversion | Converting a free user to a paying customer | The business metric. Describes the outcome, not the mechanism. |
Why the Distinction Matters
The term you use internally shapes the design you build. Teams that call their system a “paywall” tend to build restrictive experiences — locks on content, aggressive pop-ups, hard blocks. Teams that call it an “upgrade flow” tend to build aspirational experiences — showcasing what premium offers, respecting the user’s choice to stay free, treating the upgrade prompt as a moment of invitation rather than denial.
Consider the difference:
- “We need a paywall on the AI feature” → Leads to: a lock icon, no preview, an immediate block when the user clicks the button.
- “We need an upgrade prompt for the AI feature” → Leads to: a premium badge indicating the feature exists, a modal showing what AI can do, pricing and a CTA, and a respectful “Maybe later” dismiss.
Both achieve the same business goal (converting free users to paid), but the second framing produces a design that converts better because it sells the feature rather than gatekeeping it.
The Relationship Between Terms
These terms describe different layers of the same system:
Business strategy: Freemium conversion (the goal)
↓
Interaction design: Upgrade prompt (the UI)
↓
Technical mechanism: Feature gate (the code)
↓
Visual indicator: Premium badge (the marker)
The business strategy decides which features are free and which are paid. The interaction design determines what the user sees when they encounter a paid feature. The technical mechanism is the code that checks subscription status and conditionally renders UI. The visual indicator tells users which features are gated before they click.
When these layers are aligned, the experience feels coherent. When they’re misaligned — for example, a “growth” business strategy paired with a “restriction” icon (a lock) — the user experience sends contradictory signals.
Recommended Component Naming
Name your components after what the user experiences, not the business strategy:
| Component | Name | Why |
|---|---|---|
| The modal/dialog | UpgradeModal | The user sees a prompt to upgrade — positive framing |
| The conditional renderer | FeatureGate | Neutral engineering term — describes the mechanism accurately |
| The check-and-trigger hook | useFeatureGate | Pairs with the component name |
| The visual indicator | PremiumBadge | Describes what it is (a badge) and what it means (premium) |
Avoid names like PaywallModal, LockedFeature, or RestrictedContent — they encode the restriction frame into the code itself, making it harder for the team to think about the design in growth-oriented terms.## When to Show the Upgrade Prompt
The “Moment of Value” Principle
The most important research finding in freemium UX is that timing matters more than design. Showing upgrade prompts based on user engagement metrics results in 2-3x better conversion rates compared to static or arbitrary timing (Monetizely).
Users who understand the value proposition before encountering an upgrade prompt are 30% more likely to convert (ContextSDK).
Three Timing Strategies
| Strategy | Trigger | Conversion Rate | Best For |
|---|---|---|---|
| At signup | Before any product access | Highest ARPU, but 30-40% fewer users | Strong brand, urgent pain point |
| At activation | After setup, during trial | 15-25% of trial users | Products with guided onboarding |
| At value moment | After user experiences benefit | 2-5% of free users, but massive funnel | Products without strong brand recognition |
In crowded markets, the value-moment approach captures 3-4x more market share than upfront gating because it allows users to experience the product before committing money (Monetizely).
Contextual Triggers (What Top Products Do)
The best upgrade prompts appear when the user demonstrates intent, not at arbitrary moments:
| Product | Trigger | Why It Works |
|---|---|---|
| Slack | User searches beyond 10k message limit | Targets users with demonstrated need |
| Spotify | User exceeds 6 skips/hour | Frames limit as “you discovered a Premium feature” |
| Grammarly | Advanced writing issues detected | Shows tangible value gap in real time |
| Zapier | User clicks a higher-tier feature | Leverages active momentum and clear intent |
| Loom | User finishes recording a video | Appears at natural engagement milestone |
| Canva | User clicks a premium template/asset | Shows premium content inline with free content |
| Harvest | User hovers over disabled premium feature | Tooltip appears only on interaction, non-intrusive |
Key pattern: The best triggers happen at natural interaction points: not random pop-ups, not on page load, and not on a timer. The user should be actively trying to do something that requires premium.
The Golden Rule
Show the upgrade prompt when the user is trying to do something they can’t, not when you want to sell them something they haven’t asked for.## The Three Types of Feature Gates
Research from Alex Debecker’s study on feature gating identifies three distinct gate types, each suited to different product patterns:
1. Access Gate
Behaviour: The feature is entirely blocked for free users. Example: “Upgrade to use AI generation” Best for: Features with no free equivalent — the feature simply doesn’t exist on the free tier.
User clicks "AI Generate"
→ Check: is user premium?
→ No → Show UpgradeModal
→ Yes → Execute the action
This is the most common gate type. The user clicks a button, the system checks their subscription, and either the action proceeds or the upgrade modal appears.
2. Usage Gate
Behaviour: The feature works up to a limit, then requires an upgrade. Example: “3 of 10 exports used this month” Best for: Metered resources where free users get a taste of the value before hitting the limit.
User clicks "Generate"
→ Check: is usage under quota?
→ No → Show UpgradeModal (with usage context)
→ Yes → Execute the action, increment counter
Usage gates are the most conversion-friendly because users have already experienced value before hitting the wall. The upgrade prompt can reference their specific usage: “You’ve used 10 of 10 free generations this month. Upgrade for unlimited.”
3. Save Gate
Behaviour: The feature works fully, but the output can’t be persisted without an upgrade. Example: “Preview your changes freely. Saving requires Premium.” Best for: Creative and customisation tools where the free experience is the sales demo.
User customises settings (freely — no gate)
→ User clicks "Save"
→ Check: is user premium?
→ No → Show UpgradeModal
→ Yes → Save changes
Save gates are the highest-converting gate type because they leverage sunk cost psychology. The user has invested effort configuring their settings. They’ve seen the preview. They want to keep it. The upgrade prompt appears at the moment of maximum desire.
Combining Gates
A product can use multiple gate types for different features:
| Feature | Gate Type | Rationale |
|---|---|---|
| AI content generation | Access gate | No free equivalent — AI simply doesn’t work without premium |
| Monthly exports | Usage gate | Free users get 5/month — enough to see value |
| Brand customisation | Save gate | Preview free, save requires premium — maximises desire |
Visual Indicators for Gated Features
Users should be able to recognise gated features before clicking, not after. Add a small, consistent premium icon (see Choosing the Right Icon for Premium Features) next to any feature that will trigger an upgrade prompt.
Paradoxically, pre-labelling premium features increases clicks on those features because it creates curiosity. Users want to know what they’re missing. The premium badge acts as an invitation, not a barrier.## Upgrade Modal Design
The 3-Second Rule
Users must grasp the offer’s value within 3 seconds (Apphud). The visual hierarchy should flow:
1. Headline (what you get)
2. Feature highlights (3-5 bullets max)
3. Price (transparent, no surprises)
4. CTA button (action-oriented)
5. Dismiss option (respectful, visible)
Content Guidelines
- Keep total text under 150 words. The modal is not a landing page.
- 3-5 benefit bullets with icons. Fewer than 3 feels thin; more than 5 creates cognitive overload.
- Outcome-focused labels. Not “Access to the AI content generation API endpoint” but “Save 4 hours per document with AI writing.”
- Modal size: Limit to 20-25% of the interface area. Never use more than one modal consecutively (Userpilot).
Feature List Design
Each benefit should have an icon, a short title, and an optional one-line description:
| Quality | Example |
|---|---|
| Bad | ”Access to the AI content generation API endpoint” |
| Okay | ”AI-generated content tailored to your business” |
| Good | ”Save 4 hours per document with AI writing” |
| Best | ”AI writing — save 4 hours per document” (title + description split) |
The icon should represent the feature category (e.g., a Wand for AI, a Palette for branding), NOT the premium indicator. The premium indicator goes on the trigger (the button the user clicked), not inside the modal — the user already knows they’re looking at a premium prompt.
Layout
Two validated layouts, chosen based on whether you have an illustration:
Without illustration (single column, max-w-md):
┌──────────────────────────────┐
│ [X] │
│ │
│ Title │
│ Description │
│ │
│ ✓ Feature 1 │
│ ✓ Feature 2 │
│ ✓ Feature 3 │
│ │
│ $19/month │
│ Cancel anytime │
│ │
│ [ Upgrade Now ] │
│ Maybe later │
│ │
│ Secured by Stripe │
└──────────────────────────────┘
With illustration (two-column on sm+, max-w-xl):
┌──────────────────────────────────────┐
│ [X] │
│ │
│ Title │
│ Description │
│ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ │ │ ✓ Feature 1 │ │
│ │ [image] │ │ ✓ Feature 2 │ │
│ │ │ │ ✓ Feature 3 │ │
│ └──────────┘ └──────────────────┘ │
│ │
│ $19/month │
│ Cancel anytime │
│ │
│ [ Upgrade Now ] │
│ Maybe later │
│ │
│ Secured by Stripe │
└──────────────────────────────────────┘
The illustration should be hidden on mobile (hidden sm:flex) — there isn’t enough horizontal space. The modal collapses to single-column automatically.## Pricing Display
Show the Price (Always)
Research is unambiguous: transparent pricing builds trust. Hiding or delaying price reveals destroys trust and increases bounce at checkout (Apphud).
If users see the price for the first time at Stripe Checkout, they experience sticker shock: the price feels surprising even if it’s reasonable, because the context switched from “product experience” to “payment transaction.” Showing the price in the modal normalises it before the user reaches checkout.
Price Display Format
$19 ← Large, bold (text-3xl font-bold)
/month ← Small, muted (text-sm text-muted-foreground)
Cancel anytime ← Extra small, muted (text-xs text-muted-foreground)
Always include the billing frequency. “$19” alone is ambiguous — monthly? annual? per use? State it explicitly: “$19/month, billed monthly” or “€9.90/month.”
Anchor Pricing for Annual Plans
If offering both monthly and annual options, display the monthly equivalent and savings:
- Monthly: “€9.90/month”
- Annual: “€7.90/month, billed annually — Save 20%”
A 2025 test found that defaulting to annual billing increases annual plan adoption by 19% (InfluenceFlow).
However, for upgrade modals (as opposed to pricing pages), showing a single price is cleaner. Let Stripe’s native annual upsell handle the monthly-vs-annual choice during checkout (see Stripe Checkout Integration).## CTA Copy and Button Design
Copy That Converts
Avoid generic, transactional language. Use action-oriented copy that emphasises user benefit:
| Bad | Okay | Good | Best |
|---|---|---|---|
| Subscribe | Upgrade | Upgrade to Premium | Unlock Unlimited Access |
| Buy | Purchase | Start Premium | Start Creating with AI |
| Pay Now | Get Premium | Upgrade Now | Get 100 AI Generations |
Subtle CTA changes can lift conversion by several percentage points. The word “Start” outperforms “Buy” because it implies beginning a journey rather than completing a transaction (Apphud).
Button Visual Design
- High contrast against the modal background (use your primary brand colour)
- Full-width on mobile, adequate width on desktop
- Larger than the dismiss button: visual hierarchy must make the primary action obvious
- Loading state with spinner and “Processing…” during checkout session creation (never let the button go silent while the API call is in flight)
Loading State
When the user clicks the CTA, you’re making an API call to create a Stripe Checkout session. This takes 500ms-2s. During this time:
- Replace the CTA text with a spinner + “Processing…”
- Disable both the CTA and dismiss buttons (prevent double-clicks and premature dismissal)
- Set
aria-busy="true"on the CTA for screen readers
<Button
onClick={handleUpgrade}
loading={isLoading}
loadingText="Processing..."
disabled={isLoading}
>
Upgrade Now
</Button>
Trust Signals and Objection Handling
Why Trust Signals Matter
Users at the upgrade modal are weighing risk vs. reward. They’re asking:
- “Can I cancel if I don’t like it?”
- “Is my payment information safe?”
- “Will this actually be worth the money?”
Trust signals reduce perceived risk and address these objections without the user having to ask.
Effective Trust Signals
| Signal | Example | Impact |
|---|---|---|
| Cancel anytime | ”Cancel anytime, no questions asked” | Reduces lock-in fear (highest-impact single signal) |
| Free trial | ”Try all premium features free for 7 days” | Eliminates financial risk entirely |
| Money-back guarantee | ”30-day money-back guarantee” | Reverses risk — user has nothing to lose |
| Social proof | ”Trusted by 2,400+ businesses” | Normalises the purchase decision |
| Security badge | ”Secured by Stripe” | Payment trust — users recognise Stripe |
| Rating/reviews | ”4.8/5 from 500+ reviews” | Third-party validation |
At minimum, include “Cancel anytime” and “Secured by Stripe.” These cost nothing to implement and address the two most common objections (lock-in and payment safety).
Placement
Trust signals go below the CTA button, in small muted text. They should be visible but not compete with the primary action:
[ Upgrade Now ] ← Primary CTA
Maybe later ← Dismiss
Cancel anytime · Secured by Stripe ← Trust signal (text-xs, text-muted-foreground)
Dismiss Behaviour and Re-engagement
Always Provide a Clear Exit
Every modal must have a visible, unambiguous dismiss option. Users who feel trapped become hostile to the product (Userpilot).
Three dismiss mechanisms (all should be present):
- Text button: “Maybe later” or “Not now” — visible, low-pressure, below the CTA
- X button: Top-right corner, standard dialog close
- Escape key: Keyboard accessibility (handled automatically by Radix Dialog, Headless UI, etc.)
Do not use an X button as the only dismiss option. An X-only dismiss pattern feels like the product is trying to make it hard to leave. A “Maybe later” text button communicates respect for the user’s decision.
After Dismiss: Re-engagement Strategy
- Don’t re-show the modal immediately. If the user dismissed it, showing it again on the next click feels aggressive.
- Wait for a new context. Re-trigger only when the user hits a different premium feature or starts a new session.
- Rotate copy across exposures. Dropbox’s approach of showing different messaging each time prevents habituation — the same gate, but with varied feature highlights (Appcues).
A reasonable policy: after dismiss, don’t show the same modal again for the current session. This can be implemented with a simple session-level flag (sessionStorage or in-memory state).## Unauthenticated vs Authenticated Flows
Most SaaS products require login before accessing any features, making the upsell flow straightforward: the user is always authenticated, and the modal leads directly to Stripe Checkout.
However, some products use progressive authentication: letting users access basic features without an account. These products need a dual-mode upgrade flow:
| User State | Need | CTA | Next Step |
|---|---|---|---|
| Unauthenticated | Create account first | ”Create Free Account” | Auth flow, then back to feature |
| Authenticated, free | Upgrade to premium | ”Upgrade to Premium” | Stripe Checkout |
Dual-Mode Modal
If your product supports progressive auth, the upgrade modal should adapt:
- Unauthenticated mode: Highlight the free tier benefits (“No credit card required”), show 3-4 free features, subtle premium teaser. CTA: “Create Free Account.” Clicking closes the modal and opens the auth flow.
- Authenticated mode: Show premium features and pricing. CTA: “Upgrade Now.” Clicking creates a checkout session.
This can be a single component with an isUnauthenticated prop, or two entirely separate components. A single component with a prop is simpler to maintain:
<UpgradeModal
isUnauthenticated={!session}
// ... standard props
unauthCtaText="Create Free Account"
onCreateAccount={() => router.push("/login")}
/>
Stripe Checkout Integration
The Standard Checkout Flow
User clicks "Upgrade Now" in modal
→ Frontend: POST /api/subscription/checkout
→ Backend: stripe.checkout.sessions.create(config)
→ Backend: return { url: session.url }
→ Frontend: window.location.assign(url)
→ User completes payment on Stripe Checkout
→ Stripe redirects to success_url
→ Frontend: verify session (optional, for instant UI update)
→ Stripe webhook: update subscription record in database
Checkout Session Configuration
These settings are recommended for all SaaS subscription checkouts:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: userEmail,
// Enable coupon codes — free conversion tool
allow_promotion_codes: true,
// Required for tax compliance (EU VAT, etc.)
billing_address_collection: "required",
tax_id_collection: { enabled: true },
// Your subscription price
line_items: [{ price: STRIPE_PRICE_ID, quantity: 1 }],
// Track which product and user for webhook processing
subscription_data: {
metadata: { product: "your-product", userId: userId },
},
// Return URLs
success_url: `${origin}/account?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/account?canceled=true`,
});
Stripe’s Native Annual Upsell
Stripe Checkout has a built-in upsell feature that offers customers a longer-term plan during checkout. If you have both a monthly and annual price on the same Stripe Product, Stripe will automatically show “Save X% with annual billing” to the customer.
Requirements:
- Both prices must be on the same Stripe Product
- Both must be recurring, non-metered, same currency
- Checkout must use
mode: 'subscription'with a single recurring price
This is a free conversion optimisation that requires zero UI code. Your modal shows the monthly price; Stripe offers the annual option during checkout.
Dual Activation Path
Use both webhook-driven and session-verification activation:
- Webhook (reliable, async): Stripe sends
checkout.session.completed→ your webhook updates the subscription record in the database. This is the authoritative path. - Session verification (immediate, sync): After redirect to
success_url, the frontend callsPOST /api/subscription/verify-sessionwith the session ID. The backend checks the session status with Stripe and activates the subscription immediately.
Why both? The webhook is reliable but async — it might arrive seconds after the redirect. Session verification gives the user instant feedback: the premium features unlock immediately after returning from checkout, without waiting for the webhook.
Standard Webhook Events
| Event | Action |
|---|---|
checkout.session.completed | Create/update subscription record. Mark user as premium. |
customer.subscription.updated | Update subscription status, period end, cancellation state. |
customer.subscription.deleted | Mark user as non-premium. |
invoice.paid | Reset usage counters (if using metered billing). |
API Route Structure
Every app with subscriptions should have these routes:
| Route | Method | Purpose |
| ---------------------------------- | ------ | -------------------------------------- | ------------------------------- |
| /api/subscription | GET | Fetch subscription status |
| /api/subscription/checkout | POST | Create Stripe Checkout session |
| /api/subscription/portal | POST | Create Stripe Billing Portal session |
| /api/subscription/verify-session | POST | Verify checkout session after redirect |
| /api/webhooks/stripe | POST | Handle Stripe events | ## React Component Architecture |
The Three-Layer Pattern
The cleanest React pattern for feature gating uses three layers (Guillermo del Parra):
Layer 1: Pure check function → Returns boolean
Layer 2: Hook → Reads from context, returns check + trigger
Layer 3: Component → Uses hook, conditionally renders children
// Layer 1: Pure logic (testable, no React dependency)
function checkHasAccess(subscription: Subscription | null): boolean {
return subscription?.isSubscribed ?? false;
}
// Layer 2: Hook (reads from context, returns check + modal trigger)
function useFeatureGate({ isPremium, showUpgradeModal }) {
const hasAccess = isPremium;
const gate = useCallback(() => {
if (!hasAccess) {
showUpgradeModal();
return false;
}
return true;
}, [hasAccess, showUpgradeModal]);
return { hasAccess, gate };
}
// Layer 3: Component (declarative conditional rendering)
function FeatureGate({ hasAccess, children, fallback }) {
return hasAccess ? children : (fallback ?? null);
}
Context Separation
Keep subscription concerns in a dedicated context. Do not mix subscription state with app-specific domain state (customisation settings, product configuration, file uploads).
The React community consensus is that Context should model a single cross-cutting concern (Kent C. Dodds). Mixing subscription with domain logic creates:
- Unnecessary re-renders: Changing a colour setting re-renders every subscription consumer
- Tight coupling: The subscription hook can’t be reused across apps
- Testing difficulty: Can’t test the subscription flow without mocking domain state
Bad: Single mixed context
interface AppContextType {
// Subscription concern
subscription: Subscription | null;
isPremium: boolean;
showUpgradeModal: boolean;
createCheckoutSession: () => Promise<void>;
// Domain concern — should NOT be here
customization: Customization;
updateCustomization: (data: Partial<Customization>) => Promise<void>;
uploadLogo: (file: File) => Promise<string>;
}
Good: Separated contexts
// Subscription context (shareable pattern)
interface SubscriptionContextType {
subscription: Subscription | null;
loading: boolean;
isPremium: boolean;
showUpgradeModal: boolean;
setShowUpgradeModal: (show: boolean) => void;
fetchSubscription: () => Promise<void>;
createCheckoutSession: () => Promise<void>;
openBillingPortal: () => Promise<void>;
}
// Domain context (app-specific)
interface CustomizationContextType {
customization: Customization;
loading: boolean;
updateCustomization: (data: Partial<Customization>) => Promise<void>;
uploadLogo: (file: File) => Promise<string>;
}
Shared vs Per-App Code
| Code | Where It Lives | Why |
|---|---|---|
UpgradeModal component | Shared UI library | Same UI pattern across all products |
FeatureGate component | Shared UI library | Same conditional rendering logic |
PremiumBadge component | Shared UI library | Consistent visual indicator |
useFeatureGate hook | Shared UI library | Same check-and-trigger logic |
Subscription type | Shared types | Consistent data shape |
SubscriptionProvider context | Per-app | Different auth, different API routes, different Firestore structure |
| API routes | Per-app | Different products, different metadata, different webhook handling |
The shared hook (useFeatureGate) takes isPremium and showUpgradeModal as options rather than reading from context directly. This is because the shared library doesn’t know about each app’s SubscriptionProvider. Each app creates a thin wrapper:
// Per-app convenience wrapper (2 lines of code)
function useAppFeatureGate() {
const { isPremium, setShowUpgradeModal } = useSubscription();
return useFeatureGate({
isPremium,
showUpgradeModal: () => setShowUpgradeModal(true),
});
}
Frontend Gates Are UX Only
Critical point: Frontend feature gates are a UX enhancement, not a security boundary. The backend must independently verify subscription status on every API call that performs a premium action.
Never trust the client-side isPremium flag for anything security-sensitive. A user could modify the JavaScript, bypass the FeatureGate component, and call the premium API directly. The server must check:
// Server-side: always verify subscription status
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return new Response("Unauthorized", { status: 401 });
// Always check subscription server-side, regardless of client state
const subscription = await getSubscription(session.user.id);
if (!subscription?.isSubscribed) {
return new Response("Premium required", { status: 403 });
}
// ... proceed with premium action
}
Component Specifications
UpgradeModal
A configurable dialog that shows product-specific upgrade content. All content is received via props — no hardcoded product information.
Props:
interface UpgradeFeature {
icon: LucideIcon; // or any icon component type
title: string;
description?: string;
}
interface UpgradeModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
features: UpgradeFeature[]; // 3-5 benefit items
price: string; // e.g., "$19", "€9.90"
pricePeriod: string; // e.g., "/month"
priceSubtitle?: string; // e.g., "Cancel anytime"
title: string; // e.g., "Upgrade to Premium"
description?: string;
ctaText: string; // e.g., "Upgrade Now"
dismissText: string; // e.g., "Maybe later"
onUpgrade: () => void | Promise<void>;
isLoading?: boolean;
illustration?: ReactNode; // Optional left-column illustration
trustSignal?: string; // e.g., "Cancel anytime · Secured by Stripe"
// Dual-mode (for progressive auth products)
isUnauthenticated?: boolean;
freeTierContent?: ReactNode;
unauthCtaText?: string;
onCreateAccount?: () => void;
}
Key design decisions:
| Decision | Standard | Rationale |
|---|---|---|
| Pricing shown | Always | Hiding price causes sticker shock at checkout |
| Dismiss button | Always visible (“Maybe later”) | X-only dismiss feels like a trap |
| Feature list style | Icon in bg-primary/10 circle + title + optional description | Outcome-focused, scannable |
| Trust signal | Below CTA | ”Cancel anytime” reduces commitment anxiety |
| Illustration | Optional prop | Some products have one, some don’t — both are valid |
| Modal width | sm:max-w-md (no illustration), sm:max-w-xl (with illustration) | Responsive, not cramped |
| Loading state | Spinner + “Processing…” replaces CTA text | Prevents double-clicks, communicates progress |
FeatureGate
A declarative component that conditionally renders children based on subscription status:
interface FeatureGateProps {
hasAccess: boolean;
children: ReactNode;
fallback?: ReactNode; // default: null
}
function FeatureGate({ hasAccess, children, fallback = null }) {
return <>{hasAccess ? children : fallback}</>;
}
Why no gate type prop? The three gate types (access, usage, save) differ in where the gate is placed, not in the component’s behaviour. FeatureGate handles the simplest case (access gate — show or hide a feature). Save gates and usage gates are better expressed as imperative checks in event handlers:
// Access gate — declarative (use FeatureGate)
<FeatureGate hasAccess={isPremium}>
<AIGenerateButton />
</FeatureGate>;
// Save gate — imperative (use the hook)
const { gate } = useFeatureGate(options);
const handleSave = () => {
if (!gate()) return; // shows modal if not premium
// ... save logic
};
PremiumBadge
A small visual indicator placed next to UI elements that trigger an upgrade prompt. See Choosing the Right Icon for Premium Features for the full research on icon selection.
interface PremiumBadgeProps {
size?: "sm" | "md";
tooltip?: string;
className?: string;
}
sm(14px): Inline with text in buttons, menu items, labelsmd(16px in 20px circle): In cards, feature lists, standalone
useFeatureGate Hook
Wraps the subscription check and modal trigger into a single call:
interface UseFeatureGateOptions {
isPremium: boolean;
showUpgradeModal: () => void;
}
interface UseFeatureGateReturn {
hasAccess: boolean;
gate: () => boolean; // returns true if access granted, false if modal shown
}
function useFeatureGate({ isPremium, showUpgradeModal }): UseFeatureGateReturn {
const hasAccess = isPremium;
const gate = useCallback(() => {
if (!hasAccess) {
showUpgradeModal();
return false;
}
return true;
}, [hasAccess, showUpgradeModal]);
return { hasAccess, gate };
}
Subscription Type
A standard shape for subscription data, stored in the database and provided via context:
interface Subscription {
isSubscribed: boolean;
subscriptionStatus: string | null; // "active", "past_due", "canceled", etc.
currentPeriodEnd: Date | null;
cancelAtPeriodEnd: boolean;
customerId: string | null; // Stripe customer ID
}
Accessibility Requirements
Modal Accessibility
The upgrade modal inherits most requirements from the underlying dialog primitive (Radix Dialog, Headless UI, etc.), but verify these are met:
| Requirement | Implementation | WCAG |
|---|---|---|
| Focus trapped inside modal when open | Handled by dialog primitive | 2.4.3 |
Escape key closes modal | Handled by dialog primitive | 2.1.1 |
aria-labelledby points to title | Use DialogTitle component | 4.1.2 |
aria-describedby points to description | Add to DialogContent | 4.1.2 |
| Focus returns to trigger element on close | Handled by dialog primitive | 2.4.3 |
| ”Maybe later” and CTA both keyboard-focusable | Use <button> elements, not <span> or <div> | 2.1.1 |
| Loading state communicated | aria-busy="true" on CTA + aria-live="polite" for status | 4.1.3 |
PremiumBadge Accessibility
| Requirement | Implementation | WCAG |
|---|---|---|
| Icon has text alternative | aria-label="Premium feature" | 1.1.1 |
| Tooltip accessible via keyboard | Use proper Tooltip component (not title attribute) | 2.1.1 |
| Icon decorative when adjacent text explains | aria-hidden="true" when next to “Premium” text | 1.1.1 |
FeatureGate Accessibility
| Requirement | Implementation | WCAG |
|---|---|---|
| Gated content doesn’t cause layout shift | Fallback matches children dimensions where possible | 2.3.1 |
| Disabled state communicated | aria-disabled="true" + visual dimming | 4.1.2 |
Colour Contrast
| Element | Requirement | | ------------------------- | ------------------------------------ | ----------------------- | | Modal text | 4.5:1 against card background | | Feature list icons | Decorative (no contrast requirement) | | Trust signal text (muted) | 4.5:1 against modal background | | PremiumBadge icon | 3:1 (non-text contrast) | | CTA button text | 4.5:1 against button background | ## Common Anti-Patterns |
1. No Pricing in the Modal
The user sees the price for the first time at Stripe Checkout. Sticker shock. Abandonment. Always show pricing in the modal so the user makes the decision before leaving your product.
2. X-Only Dismiss
No text dismiss button — only the dialog close (X). This feels like the product is trying to make it hard to leave. Always include a “Maybe later” text button below the CTA.
3. Random Pop-Up Timing
Showing the upgrade modal on page load, on a timer, or at random intervals. These are spam patterns. The modal should only appear when the user attempts a premium action.
4. Generic CTA Copy
“Subscribe” is transactional. “Buy” is clinical. “Pay” is negative. Use action-oriented, benefit-focused copy: “Upgrade Now”, “Unlock Premium”, “Start Creating with AI.”
5. Mixed Context Concerns
Putting subscription state, domain state, and unrelated UI state in a single React context. This causes unnecessary re-renders, tight coupling, and makes the subscription logic impossible to reuse across products.
6. No Visual Indicators
Users discover feature gates only by clicking and getting blocked. This creates surprise and frustration. Add a small, consistent premium badge next to every gated feature so users know what to expect before clicking.
7. No Trust Signals
No “Cancel anytime.” No “Secured by Stripe.” No social proof. Users weighing the purchase have unaddressed objections. Trust signals are free and measurably improve conversion.
8. Frontend-Only Enforcement
Relying on the client-side FeatureGate component to actually prevent access to premium features. Frontend gates are UX, not security. The backend must independently verify subscription status.
9. Showing the Modal Again Immediately After Dismiss
User dismisses the modal, clicks another button, and the modal appears again. This is aggressive and disrespectful. Wait for a new session or a different feature trigger before re-showing.
10. Text Overload in the Modal
More than 150 words, long paragraphs, multiple sections. The modal is a focused prompt, not a landing page. Keep it scannable: headline, 3-5 bullets, price, CTA, dismiss.## References
- Userpilot — Modal UX Design for SaaS — Modal best practices, sizing, and dismiss patterns
- Appcues — Best Freemium Upgrade Prompts — Analysis of Slack, Spotify, Dropbox upgrade flows
- Appcues — Upselling Prompts: 8 Examples — SaaS upsell trigger patterns
- Alex Debecker — A Study in Feature Gating — Three gate types: access, usage, save
- Apphud — Design High-Converting Subscription Paywalls — Paywall design, 3-second rule, pricing transparency
- Stripe — Subscription Upsells — Native annual plan upsell during checkout
- Stripe — Freemium Pricing Explained — Freemium economics and timing strategies
- Stripe — Checkout Flow Design Strategies — Checkout UX and conversion
- ContextSDK — The Right Time to Show a Paywall — Research on timing vs. A/B testing for paywalls
- Monetizely — Strategic Timing of SaaS Monetisation — At-signup vs at-activation vs at-value-moment
- InfluenceFlow — SaaS Pricing Page Best Practices 2026 — Annual vs monthly default, anchor pricing
- Guillermo del Parra — Authorization Checks in React — Three-layer pattern for feature gates
- Kent C. Dodds — How to Use React Context Effectively — Context separation and single-concern providers
- RevenueCat — Paywall Conversion Boosters — Trust signals and objection handling
- RevenueCat — Contextual Paywall Targeting — Smart timing for mobile paywalls