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>
  );
}

Variant: blur-behind-lock

When a specific screen is gated rather than the whole product, a blurred preview of the real content — with an "Unlock" card floating over it — converts better than a cold interstitial: the user sees what they're paying for, not just a sales pitch. Use this for a single locked feature reached by navigating in; use the full paywall above for global upgrade prompts and plan comparison.

🔒

Unlock full history

Upgrade to see your complete application timeline.

Accessibility note

The blur is a visual effect only — it does nothing to stop a screen reader from reading the real content underneath, node by node (a job title, a balance, personal notes). Mark the blurred content aria-hidden / .accessibilityHidden(true) and give the lock card its own role="region"/ combined accessibility element with a label that already states what's locked and why — this was a real bug found in production, not a hypothetical.

Use Sitka's existing accent token for the lock icon and CTA — don't introduce a one-off color for this variant.

LockedContentPreview.tsx
tsx
interface LockedContentPreviewProps {
  trigger: string;
  headline: string;
  subheadline: string;
  onUnlock: () => void;
  children: React.ReactNode;
}

// Shows a blurred, non-interactive preview of the real content behind a
// lock card instead of a cold, full-screen paywall interstitial —
// aspiration converts better than a wall.
export function LockedContentPreview({
  trigger,
  headline,
  subheadline,
  onUnlock,
  children,
}: LockedContentPreviewProps) {
  return (
    <div className="relative">
      {/* The blur is only visual — aria-hidden stops screen readers from
          announcing the real content underneath (a job title, a balance,
          personal notes) node by node. */}
      <div aria-hidden="true" className="pointer-events-none select-none blur-md opacity-60">
        {children}
      </div>

      <div className="absolute inset-0 bg-black/15" />

      <div
        role="region"
        aria-label={`${headline}. ${subheadline}`}
        className="absolute inset-0 flex flex-col items-center justify-center gap-3.5 p-7"
      >
        <div className="flex h-13 w-13 items-center justify-center rounded-full bg-[rgb(var(--accent)/0.2)]">
          <LockIcon className="h-5 w-5 text-[rgb(var(--accent))]" />
        </div>
        <p className="text-center text-[18px] font-bold text-[rgb(var(--text-primary))]">{headline}</p>
        <p className="max-w-[280px] text-center text-[13px] text-[rgb(var(--text-secondary))]">{subheadline}</p>
        <button
          onClick={onUnlock}
          className="rounded-full bg-[rgb(var(--accent))] px-5 py-2.5 text-[14px] font-bold text-white hover:opacity-90"
        >
          Unlock {trigger}
        </button>
      </div>
    </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'.
  • →For the blur-behind-lock variant, hide the blurred content from assistive tech entirely (aria-hidden / .accessibilityHidden(true)) — blurring is a sighted-only cue and does not stop a screen reader from announcing what's underneath.