Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Paywall

A subscription paywall screen composed from touchcn components.

A subscription offer screen: a hero headline, a checkmarked feature list, a monthly/yearly billing toggle with a savings badge, two selectable plan cards, a purchase CTA, fine print and a restore-purchases action. Selection is local state; the CTA emits the chosen plan and billing period.

Installation

npx touchcn add paywall

This also adds the components the block composes:

badge · button · card · list · navbar · page · segmented

Usage

<tcn-paywall-block (purchase)="subscribe($event)" (close)="dismiss()" />
import { TcnPaywallBlock } from '@/components/blocks/paywall';

<TcnPaywallBlock onPurchase={(selection) => subscribe(selection)} onClose={dismiss} />
<script setup lang="ts">
import { TcnPaywallBlock } from '@/components/blocks/paywall';
</script>

<template>
  <TcnPaywallBlock @purchase="(selection) => subscribe(selection)" @close="dismiss" />
</template>

The purchase callback receives { planId, billing }. Everything here is presentational — wire the CTA and restore action to your billing SDK.

Source · Angular
export * from './tcn-paywall-block';
import { Component, output, signal } from '@angular/core';
import { TcnBadge } from '@/components/ui/badge';
import { TcnButton } from '@/components/ui/button';
import { TcnCard } from '@/components/ui/card';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnSegment, TcnSegmented } from '@/components/ui/segmented';

type Billing = 'monthly' | 'yearly';

interface Plan {
  id: string;
  name: string;
  tagline: string;
  monthly: number;
  yearly: number;
}

export interface PaywallSelection {
  planId: string;
  billing: Billing;
}

/**
 * Paywall screen — a purely presentational subscription offer. Plan and billing
 * selection are local state; the CTA emits the chosen `PaywallSelection` through
 * `purchase` (wire real billing from the parent).
 */
@Component({
  selector: 'tcn-paywall-block',
  imports: [TcnPage, TcnNavbar, TcnList, TcnListItem, TcnSegmented, TcnSegment, TcnCard, TcnBadge, TcnButton],
  template: `
    <tcn-page>
      <tcn-navbar>
        <button navbar-trailing type="button" class="tcn-navbar-action" aria-label="Close" (click)="close.emit()">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
            <path d="M6 6l12 12M18 6L6 18" />
          </svg>
        </button>
      </tcn-navbar>

      <div class="mx-auto min-h-full max-w-[430px] px-5 pt-20 pb-12">
        <div class="mb-6 text-center">
          <h1 class="text-3xl font-bold">Go Premium</h1>
          <p class="mt-2 text-[var(--color-on-surface-variant)]">Unlock every feature with a plan that fits you.</p>
        </div>

        <tcn-list>
          @for (feature of features; track feature) {
            <tcn-list-item>
              <span item-leading class="text-[var(--color-primary)]">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
                </svg>
              </span>
              {{ feature }}
            </tcn-list-item>
          }
        </tcn-list>

        <div class="mt-6">
          <tcn-segmented [(value)]="billing">
            <tcn-segment value="monthly" label="Monthly" />
            <tcn-segment value="yearly" label="Yearly" />
          </tcn-segmented>
          <div class="mt-2 text-center">
            <tcn-badge variant="success">Save 20% with yearly billing</tcn-badge>
          </div>
        </div>

        <div class="mt-4 grid grid-cols-2 gap-3" role="radiogroup" aria-label="Choose a plan">
          @for (plan of plans; track plan.id) {
            <button
              type="button"
              role="radio"
              [attr.aria-checked]="plan.id === planId()"
              (click)="planId.set(plan.id)"
              [class]="
                plan.id === planId()
                  ? 'block w-full rounded-[var(--radius-lg)] text-left transition ring-2 ring-[var(--color-primary)]'
                  : 'block w-full rounded-[var(--radius-lg)] text-left transition ring-1 ring-transparent'
              "
            >
              <tcn-card>
                <div class="text-sm font-medium text-[var(--color-on-surface-variant)]">{{ plan.name }}</div>
                <div class="mt-1 text-2xl font-bold">\${{ priceOf(plan) }}</div>
                <div class="text-xs text-[var(--color-on-surface-variant)]">per month</div>
                <div class="mt-2 text-xs text-[var(--color-on-surface-variant)]">{{ plan.tagline }}</div>
              </tcn-card>
            </button>
          }
        </div>

        <button tcnButton type="button" class="mt-6 w-full" (click)="emitPurchase()">Continue</button>

        <p class="mt-3 text-center text-xs text-[var(--color-on-surface-variant)]">
          {{ billing() === 'yearly' ? 'Billed annually. ' : '' }}Recurring billing, cancel anytime. Terms apply.
        </p>

        <div class="mt-2 text-center">
          <button tcnButton variant="ghost" size="sm" type="button" (click)="restore.emit()">Restore purchases</button>
        </div>
      </div>
    </tcn-page>
  `,
})
export class TcnPaywallBlock {
  readonly close = output<void>();
  /** Fires with the chosen plan and billing period on the CTA. */
  readonly purchase = output<PaywallSelection>();
  readonly restore = output<void>();

  // Typed as `string` so it two-way binds to the segmented control's value model.
  protected readonly billing = signal('yearly');
  protected readonly planId = signal('pro');

  protected readonly features = [
    'Unlimited projects and members',
    'Advanced analytics and exports',
    'Priority support, 24/7',
    'Early access to new features',
  ];

  protected readonly plans: Plan[] = [
    { id: 'plus', name: 'Plus', tagline: 'For getting started', monthly: 4.99, yearly: 3.99 },
    { id: 'pro', name: 'Pro', tagline: 'For power users', monthly: 9.99, yearly: 7.99 },
  ];

  protected priceOf(plan: Plan): string {
    return (this.billing() === 'yearly' ? plan.yearly : plan.monthly).toFixed(2);
  }

  protected emitPurchase(): void {
    this.purchase.emit({ planId: this.planId(), billing: this.billing() as Billing });
  }
}
Source · React
export * from './tcn-paywall-block';
import { useState } from 'react';
import { TcnBadge } from '@/components/ui/badge';
import { TcnButton } from '@/components/ui/button';
import { TcnCard } from '@/components/ui/card';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnSegment, TcnSegmented } from '@/components/ui/segmented';

type Billing = 'monthly' | 'yearly';

interface Plan {
  id: string;
  name: string;
  tagline: string;
  monthly: number;
  yearly: number;
}

export interface PaywallSelection {
  planId: string;
  billing: Billing;
}

export interface TcnPaywallBlockProps {
  onClose?(): void;
  /** Fires with the chosen plan and billing period on the CTA. */
  onPurchase?(selection: PaywallSelection): void;
  onRestore?(): void;
}

const features = [
  'Unlimited projects and members',
  'Advanced analytics and exports',
  'Priority support, 24/7',
  'Early access to new features',
];

const plans: Plan[] = [
  { id: 'plus', name: 'Plus', tagline: 'For getting started', monthly: 4.99, yearly: 3.99 },
  { id: 'pro', name: 'Pro', tagline: 'For power users', monthly: 9.99, yearly: 7.99 },
];

const CheckIcon = () => (
  <span className="text-[var(--color-primary)]">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  </span>
);

/**
 * Paywall screen — a purely presentational subscription offer. Plan and billing
 * selection are local state; the CTA emits the chosen `PaywallSelection` through
 * `onPurchase` (wire real billing from the parent).
 */
export function TcnPaywallBlock({ onClose, onPurchase, onRestore }: TcnPaywallBlockProps) {
  const [billing, setBilling] = useState<Billing>('yearly');
  const [planId, setPlanId] = useState('pro');

  const close = (
    <button type="button" className="tcn-navbar-action" aria-label="Close" onClick={() => onClose?.()}>
      <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
        <path d="M6 6l12 12M18 6L6 18" />
      </svg>
    </button>
  );

  return (
    <TcnPage>
      <TcnNavbar trailing={close} />
      <div className="mx-auto min-h-full max-w-[430px] px-5 pt-20 pb-12">
        <div className="mb-6 text-center">
          <h1 className="text-3xl font-bold">Go Premium</h1>
          <p className="mt-2 text-[var(--color-on-surface-variant)]">
            Unlock every feature with a plan that fits you.
          </p>
        </div>

        <TcnList>
          {features.map((feature) => (
            <TcnListItem key={feature} leading={<CheckIcon />}>
              {feature}
            </TcnListItem>
          ))}
        </TcnList>

        <div className="mt-6">
          <TcnSegmented value={billing} onValueChange={(value) => setBilling(value as Billing)}>
            <TcnSegment value="monthly">Monthly</TcnSegment>
            <TcnSegment value="yearly">
              <span className="inline-flex items-center gap-1.5">
                Yearly
                <TcnBadge variant="success">Save 20%</TcnBadge>
              </span>
            </TcnSegment>
          </TcnSegmented>
        </div>

        <div className="mt-4 grid grid-cols-2 gap-3" role="radiogroup" aria-label="Choose a plan">
          {plans.map((plan) => {
            const price = billing === 'yearly' ? plan.yearly : plan.monthly;
            const selected = plan.id === planId;
            return (
              <button
                key={plan.id}
                type="button"
                role="radio"
                aria-checked={selected}
                onClick={() => setPlanId(plan.id)}
                className={`block w-full rounded-[var(--radius-lg)] text-left transition ${
                  selected ? 'ring-2 ring-[var(--color-primary)]' : 'ring-1 ring-transparent'
                }`}
              >
                <TcnCard>
                  <div className="text-sm font-medium text-[var(--color-on-surface-variant)]">{plan.name}</div>
                  <div className="mt-1 text-2xl font-bold">${price.toFixed(2)}</div>
                  <div className="text-xs text-[var(--color-on-surface-variant)]">per month</div>
                  <div className="mt-2 text-xs text-[var(--color-on-surface-variant)]">{plan.tagline}</div>
                </TcnCard>
              </button>
            );
          })}
        </div>

        <TcnButton className="mt-6 w-full" onClick={() => onPurchase?.({ planId, billing })}>
          Continue
        </TcnButton>

        <p className="mt-3 text-center text-xs text-[var(--color-on-surface-variant)]">
          {billing === 'yearly' ? 'Billed annually. ' : ''}Recurring billing, cancel anytime. Terms apply.
        </p>

        <div className="mt-2 text-center">
          <TcnButton variant="ghost" size="sm" onClick={() => onRestore?.()}>
            Restore purchases
          </TcnButton>
        </div>
      </div>
    </TcnPage>
  );
}
Source · Vue
<script lang="ts">
export type Billing = 'monthly' | 'yearly';

export interface PaywallSelection {
  planId: string;
  billing: Billing;
}
</script>

<script setup lang="ts">
import { ref } from 'vue';
import { TcnBadge } from '@/components/ui/badge';
import { TcnButton } from '@/components/ui/button';
import { TcnCard } from '@/components/ui/card';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnSegment, TcnSegmented } from '@/components/ui/segmented';

interface Plan {
  id: string;
  name: string;
  tagline: string;
  monthly: number;
  yearly: number;
}

/**
 * Paywall screen — a purely presentational subscription offer. Plan and billing
 * selection are local state; the CTA emits the chosen `PaywallSelection` through
 * `purchase` (wire real billing from the parent).
 */
const emit = defineEmits<{
  close: [];
  /** Fires with the chosen plan and billing period on the CTA. */
  purchase: [selection: PaywallSelection];
  restore: [];
}>();

const features = [
  'Unlimited projects and members',
  'Advanced analytics and exports',
  'Priority support, 24/7',
  'Early access to new features',
];

const plans: Plan[] = [
  { id: 'plus', name: 'Plus', tagline: 'For getting started', monthly: 4.99, yearly: 3.99 },
  { id: 'pro', name: 'Pro', tagline: 'For power users', monthly: 9.99, yearly: 7.99 },
];

const billing = ref<Billing>('yearly');
const planId = ref('pro');

const priceOf = (plan: Plan): number => (billing.value === 'yearly' ? plan.yearly : plan.monthly);
</script>

<template>
  <TcnPage>
    <TcnNavbar>
      <template #trailing>
        <button type="button" class="tcn-navbar-action" aria-label="Close" @click="emit('close')">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
            <path d="M6 6l12 12M18 6L6 18" />
          </svg>
        </button>
      </template>
    </TcnNavbar>
    <div class="mx-auto min-h-full max-w-[430px] px-5 pt-20 pb-12">
      <div class="mb-6 text-center">
        <h1 class="text-3xl font-bold">Go Premium</h1>
        <p class="mt-2 text-[var(--color-on-surface-variant)]">Unlock every feature with a plan that fits you.</p>
      </div>

      <TcnList>
        <TcnListItem v-for="feature in features" :key="feature">
          <template #leading>
            <span class="text-[var(--color-primary)]">
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
              </svg>
            </span>
          </template>
          {{ feature }}
        </TcnListItem>
      </TcnList>

      <div class="mt-6">
        <TcnSegmented v-model="billing">
          <TcnSegment value="monthly">Monthly</TcnSegment>
          <TcnSegment value="yearly">
            <span class="inline-flex items-center gap-1.5">
              Yearly
              <TcnBadge variant="success">Save 20%</TcnBadge>
            </span>
          </TcnSegment>
        </TcnSegmented>
      </div>

      <div class="mt-4 grid grid-cols-2 gap-3" role="radiogroup" aria-label="Choose a plan">
        <button
          v-for="plan in plans"
          :key="plan.id"
          type="button"
          role="radio"
          :aria-checked="plan.id === planId"
          class="block w-full rounded-[var(--radius-lg)] text-left transition"
          :class="plan.id === planId ? 'ring-2 ring-[var(--color-primary)]' : 'ring-1 ring-transparent'"
          @click="planId = plan.id"
        >
          <TcnCard>
            <div class="text-sm font-medium text-[var(--color-on-surface-variant)]">{{ plan.name }}</div>
            <div class="mt-1 text-2xl font-bold">${{ priceOf(plan).toFixed(2) }}</div>
            <div class="text-xs text-[var(--color-on-surface-variant)]">per month</div>
            <div class="mt-2 text-xs text-[var(--color-on-surface-variant)]">{{ plan.tagline }}</div>
          </TcnCard>
        </button>
      </div>

      <TcnButton class="mt-6 w-full" @click="emit('purchase', { planId, billing })">Continue</TcnButton>

      <p class="mt-3 text-center text-xs text-[var(--color-on-surface-variant)]">
        {{ billing === 'yearly' ? 'Billed annually. ' : '' }}Recurring billing, cancel anytime. Terms apply.
      </p>

      <div class="mt-2 text-center">
        <TcnButton variant="ghost" size="sm" @click="emit('restore')">Restore purchases</TcnButton>
      </div>
    </div>
  </TcnPage>
</template>
export { default as TcnPaywallBlock } from './TcnPaywallBlock.vue';
export type { Billing, PaywallSelection } from './TcnPaywallBlock.vue';

Last updated on July 24, 2026

Was this page helpful?