Paywall / In-App Upsell

Full-screen upgrade prompt that appears when a user hits a feature gate or usage limit. Combines a billing period toggle, tiered plan cards, and a soft dismiss path to minimize friction while maximising conversion.

Preview

Anatomy

Every paywall screen has the same six structural zones. Keep all six visible — omitting the dismiss path or the billing toggle measurably hurts conversion.

ZonePurposeRequired
Trigger copy1–2 sentences naming the limit the user hit. Be specific — 'You've reached 20 items' outperforms 'Upgrade for more'.Yes
Billing toggleMonthly / Yearly switcher. Show the savings percentage on the annual option to push the higher-LTV choice.Yes
Plan cards2–3 tiers. Highlight one as 'Most Popular'. Show the price, feature list, and a single CTA per card.Yes
Current plan indicatorDisable the CTA on the user's active tier. Never let them re-purchase what they already have.Yes
Primary CTAThe recommended upgrade action. Use full-width buttons so the tap target is easy to hit on mobile.Yes
Dismiss path'Maybe later' or 'Continue with Free'. Small, low-contrast, but always present. Friction creates resentment.Yes

When to use

Usage limit reached

Trigger the paywall when the user attempts an action they can't complete on their current plan — adding the 21st item, running a second workspace, exporting a report.

Feature gate

Show a locked state on the feature itself (a lock icon, greyed-out card) and let the user choose to learn more. The paywall appears after they tap the locked element, not before.

Forced on first launch

Don't show the paywall before the user has experienced the product. They have no frame of reference for the value proposition.

Repeated interruptions

After a dismiss, suppress the full paywall for at least 48 hours. Use a subtle banner or badge instead.

Implementation

Paywall.tsx
tsx
"use client";

import { useState } from "react";

interface Plan {
  id: string;
  name: string;
  description: string;
  monthly: number;
  yearly: number;
  features: string[];
  cta: string;
  popular?: boolean;
  ctaDisabled?: boolean;
}

interface PaywallProps {
  plans: Plan[];
  currentPlanId?: string;
  onUpgrade?: (planId: string, billing: "monthly" | "yearly") => void;
  onDismiss?: () => void;
}

export function Paywall({ plans, currentPlanId, onUpgrade, onDismiss }: PaywallProps) {
  const [billing, setBilling] = useState<"monthly" | "yearly">("monthly");

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="paywall-title"
      className="flex flex-col items-center gap-8 rounded-2xl border border-[rgb(var(--border))]
                 bg-[rgb(var(--surface))] p-8 shadow-2xl max-w-3xl mx-auto"
    >
      {/* Header */}
      <div className="text-center space-y-2">
        <h2 id="paywall-title" className="text-[22px] font-bold text-[rgb(var(--text-primary))]">
          Unlock everything
        </h2>
        <p className="text-[14px] text-[rgb(var(--text-secondary))]">
          You&apos;ve hit the free plan limit. Upgrade to keep going.
        </p>
      </div>

      {/* Billing toggle */}
      <div className="flex items-center gap-2 rounded-full border border-[rgb(var(--border))]
                      bg-[rgb(var(--surface-raised))] p-1">
        {(["monthly", "yearly"] as const).map((period) => (
          <button
            key={period}
            onClick={() => setBilling(period)}
            className={`relative flex items-center gap-1.5 rounded-full px-4 py-1.5 text-[13px] font-medium
                        transition-all ${billing === period
                          ? "bg-[rgb(var(--surface))] shadow-sm text-[rgb(var(--text-primary))]"
                          : "text-[rgb(var(--text-secondary))] hover:text-[rgb(var(--text-primary))]"
                        }`}
            aria-pressed={billing === period}
          >
            {period.charAt(0).toUpperCase() + period.slice(1)}
            {period === "yearly" && (
              <span className="rounded-full bg-[rgb(var(--status-success))] px-1.5 py-0.5
                               text-[10px] font-bold text-white">
                −25%
              </span>
            )}
          </button>
        ))}
      </div>

      {/* Plan cards */}
      <div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-3">
        {plans.map((plan) => {
          const price = billing === "yearly" ? plan.yearly : plan.monthly;
          const isActive = plan.id === currentPlanId;
          return (
            <div
              key={plan.id}
              className={`relative flex flex-col gap-4 rounded-xl border p-5
                          ${plan.popular
                            ? "border-[rgb(var(--accent-muted))] bg-[rgb(var(--accent-subtle))]"
                            : "border-[rgb(var(--border))] bg-[rgb(var(--surface-raised))]"
                          }`}
            >
              {plan.popular && (
                <span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full
                                 border border-[rgb(var(--accent-muted))] bg-[rgb(var(--accent))]
                                 px-3 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
                  Most Popular
                </span>
              )}
              <div>
                <h3 className="text-[15px] font-semibold text-[rgb(var(--text-primary))]">
                  {plan.name}
                </h3>
                <p className="text-[12px] text-[rgb(var(--text-tertiary))]">{plan.description}</p>
              </div>
              <div className="flex items-end gap-1">
                <span className="text-[28px] font-bold leading-none text-[rgb(var(--text-primary))]">
                  {price === 0 ? "Free" : `$${price}`}
                </span>
                {price > 0 && (
                  <span className="mb-0.5 text-[12px] text-[rgb(var(--text-tertiary))]">/mo</span>
                )}
              </div>
              <ul className="flex-1 space-y-1.5">
                {plan.features.map((f) => (
                  <li key={f} className="flex items-center gap-2 text-[13px] text-[rgb(var(--text-secondary))]">
                    <span className="text-[rgb(var(--status-success))]">✓</span>
                    {f}
                  </li>
                ))}
              </ul>
              <button
                disabled={plan.ctaDisabled || isActive}
                onClick={() => onUpgrade?.(plan.id, billing)}
                className={`w-full rounded-lg py-2 text-[13px] font-semibold transition-all
                            ${plan.popular
                              ? "bg-[rgb(var(--accent))] text-white hover:opacity-90"
                              : "bg-[rgb(var(--surface))] border border-[rgb(var(--border))] text-[rgb(var(--text-primary))] hover:bg-[rgb(var(--surface-hover))]"
                            }
                            disabled:opacity-50 disabled:cursor-not-allowed`}
              >
                {isActive ? "Current plan" : plan.cta}
              </button>
            </div>
          );
        })}
      </div>

      {/* Dismiss link */}
      {onDismiss && (
        <button
          onClick={onDismiss}
          className="text-[12px] text-[rgb(var(--text-tertiary))] underline underline-offset-2
                     hover:text-[rgb(var(--text-secondary))]"
        >
          Maybe later
        </button>
      )}
    </div>
  );
}

Accessibility

  • Wrap the paywall in role='dialog' aria-modal='true' and move focus to the heading on open so screen reader users are immediately aware of the context change.
  • The billing toggle must use aria-pressed (toggle buttons) or a proper <fieldset>/<legend> with radio inputs — not two plain buttons.
  • Each plan card's CTA must include the plan name in its accessible label: 'Upgrade to Pro' not just 'Upgrade', since multiple upgrade buttons share a page.
  • The 'Maybe later' link must remain reachable via Tab. Never trap users in the paywall without an accessible exit.
  • Price amounts must use aria-label to convey currency: aria-label='$12 per month' not just '12'.