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
Unlock everything
You've hit the 20-item limit on Free. Upgrade to keep going.
Free
Get started at no cost
- ✓Up to 20 items
- ✓Basic analytics
- ✓1 workspace
Pro
For power users who need more
- ✓Unlimited items
- ✓Advanced analytics
- ✓5 workspaces
- ✓Priority support
- ✓Custom integrations
Business
For teams and organizations
- ✓Everything in Pro
- ✓Unlimited workspaces
- ✓SSO & audit logs
- ✓Dedicated CSM
- ✓Custom SLA
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.
| Zone | Purpose | Required |
|---|---|---|
| Trigger copy | 1–2 sentences naming the limit the user hit. Be specific — 'You've reached 20 items' outperforms 'Upgrade for more'. | Yes |
| Billing toggle | Monthly / Yearly switcher. Show the savings percentage on the annual option to push the higher-LTV choice. | Yes |
| Plan cards | 2–3 tiers. Highlight one as 'Most Popular'. Show the price, feature list, and a single CTA per card. | Yes |
| Current plan indicator | Disable the CTA on the user's active tier. Never let them re-purchase what they already have. | Yes |
| Primary CTA | The 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
"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'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'.