Skip to main content
tutorial Featured

Designing Freemium Upsell Flows That Convert: Upgrade Modals, Feature Gates, and Checkout Integration

A complete guide to building the upgrade workflow for freemium SaaS products. Covers upgrade modal design, feature gating patterns, pricing display, trust signals, dismiss behaviour, Stripe Checkout integration, and React component architecture. Research from Appcues, NNGroup, Stripe, and top SaaS products.

BY Group
February 11, 2026
35 min read

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

  1. Terminology
  2. When to Show the Upgrade Prompt
  3. The Three Types of Feature Gates
  4. Upgrade Modal Design
  5. Pricing Display
  6. CTA Copy and Button Design
  7. Trust Signals and Objection Handling
  8. Dismiss Behaviour and Re-engagement
  9. Unauthenticated vs Authenticated Flows
  10. Stripe Checkout Integration
  11. React Component Architecture
  12. Component Specifications
  13. Accessibility Requirements
  14. Common Anti-Patterns
  15. 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.

TermMeaningConnotation
PaywallBlocking access to content or features until paymentComes from publishing. Implies a wall between the user and something they want. The mental model is denial: the user is being kept out.
UpsellConvincing an existing user to buy a more expensive tierComes from sales. Implies the user already has something and you’re persuading them to get more. The mental model is expansion.
Feature gateA conditional check that controls access to a specific capabilityComes from engineering. Neutral: it’s a mechanism, not a business strategy. A gate can be opened or closed.
Upgrade promptThe UI that appears when a gate is triggeredDescribes what the user sees. Neutral-to-positive framing — “upgrade” implies moving up, not being blocked.
Freemium conversionConverting a free user to a paying customerThe 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.

Name your components after what the user experiences, not the business strategy:

ComponentNameWhy
The modal/dialogUpgradeModalThe user sees a prompt to upgrade — positive framing
The conditional rendererFeatureGateNeutral engineering term — describes the mechanism accurately
The check-and-trigger hookuseFeatureGatePairs with the component name
The visual indicatorPremiumBadgeDescribes 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

StrategyTriggerConversion RateBest For
At signupBefore any product accessHighest ARPU, but 30-40% fewer usersStrong brand, urgent pain point
At activationAfter setup, during trial15-25% of trial usersProducts with guided onboarding
At value momentAfter user experiences benefit2-5% of free users, but massive funnelProducts 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:

ProductTriggerWhy It Works
SlackUser searches beyond 10k message limitTargets users with demonstrated need
SpotifyUser exceeds 6 skips/hourFrames limit as “you discovered a Premium feature”
GrammarlyAdvanced writing issues detectedShows tangible value gap in real time
ZapierUser clicks a higher-tier featureLeverages active momentum and clear intent
LoomUser finishes recording a videoAppears at natural engagement milestone
CanvaUser clicks a premium template/assetShows premium content inline with free content
HarvestUser hovers over disabled premium featureTooltip 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:

FeatureGate TypeRationale
AI content generationAccess gateNo free equivalent — AI simply doesn’t work without premium
Monthly exportsUsage gateFree users get 5/month — enough to see value
Brand customisationSave gatePreview 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:

QualityExample
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:

BadOkayGoodBest
SubscribeUpgradeUpgrade to PremiumUnlock Unlimited Access
BuyPurchaseStart PremiumStart Creating with AI
Pay NowGet PremiumUpgrade NowGet 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:

  1. Replace the CTA text with a spinner + “Processing…”
  2. Disable both the CTA and dismiss buttons (prevent double-clicks and premature dismissal)
  3. 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

SignalExampleImpact
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):

  1. Text button: “Maybe later” or “Not now” — visible, low-pressure, below the CTA
  2. X button: Top-right corner, standard dialog close
  3. 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 StateNeedCTANext Step
UnauthenticatedCreate account first”Create Free Account”Auth flow, then back to feature
Authenticated, freeUpgrade 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:

  1. Webhook (reliable, async): Stripe sends checkout.session.completed → your webhook updates the subscription record in the database. This is the authoritative path.
  2. Session verification (immediate, sync): After redirect to success_url, the frontend calls POST /api/subscription/verify-session with 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

EventAction
checkout.session.completedCreate/update subscription record. Mark user as premium.
customer.subscription.updatedUpdate subscription status, period end, cancellation state.
customer.subscription.deletedMark user as non-premium.
invoice.paidReset 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

CodeWhere It LivesWhy
UpgradeModal componentShared UI librarySame UI pattern across all products
FeatureGate componentShared UI librarySame conditional rendering logic
PremiumBadge componentShared UI libraryConsistent visual indicator
useFeatureGate hookShared UI librarySame check-and-trigger logic
Subscription typeShared typesConsistent data shape
SubscriptionProvider contextPer-appDifferent auth, different API routes, different Firestore structure
API routesPer-appDifferent 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:

DecisionStandardRationale
Pricing shownAlwaysHiding price causes sticker shock at checkout
Dismiss buttonAlways visible (“Maybe later”)X-only dismiss feels like a trap
Feature list styleIcon in bg-primary/10 circle + title + optional descriptionOutcome-focused, scannable
Trust signalBelow CTA”Cancel anytime” reduces commitment anxiety
IllustrationOptional propSome products have one, some don’t — both are valid
Modal widthsm:max-w-md (no illustration), sm:max-w-xl (with illustration)Responsive, not cramped
Loading stateSpinner + “Processing…” replaces CTA textPrevents 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, labels
  • md (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

The upgrade modal inherits most requirements from the underlying dialog primitive (Radix Dialog, Headless UI, etc.), but verify these are met:

RequirementImplementationWCAG
Focus trapped inside modal when openHandled by dialog primitive2.4.3
Escape key closes modalHandled by dialog primitive2.1.1
aria-labelledby points to titleUse DialogTitle component4.1.2
aria-describedby points to descriptionAdd to DialogContent4.1.2
Focus returns to trigger element on closeHandled by dialog primitive2.4.3
”Maybe later” and CTA both keyboard-focusableUse <button> elements, not <span> or <div>2.1.1
Loading state communicatedaria-busy="true" on CTA + aria-live="polite" for status4.1.3

PremiumBadge Accessibility

RequirementImplementationWCAG
Icon has text alternativearia-label="Premium feature"1.1.1
Tooltip accessible via keyboardUse proper Tooltip component (not title attribute)2.1.1
Icon decorative when adjacent text explainsaria-hidden="true" when next to “Premium” text1.1.1

FeatureGate Accessibility

RequirementImplementationWCAG
Gated content doesn’t cause layout shiftFallback matches children dimensions where possible2.3.1
Disabled state communicatedaria-disabled="true" + visual dimming4.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

B

BY Group

Software engineering studio building high-quality products with minimal overhead.

Ready to Build Something Great?

Let's discuss your project and bring your ideas to life.

Start a Project

No credit card required • Free forever plan available