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';