Pricing page
Every SaaS ships this page. Almost none get it right.
## The starting point
```tsx
<section className="px-6 py-24">
<h2 className="text-center text-3xl font-bold">Pricing</h2>
<p className="mt-2 text-center text-gray-500">
Choose the plan that's right for you.
</p>
<div className="mt-4 flex justify-center">
<button onClick={() => setAnnual(!annual)}>
{annual ? "Annual" : "Monthly"}
</button>
</div>
<div className="mt-12 grid grid-cols-3 gap-6">
{[
{
name: "Basic",
price: annual ? 8 : 10,
features: ["5 projects", "1 GB storage", "Email support"],
},
{
name: "Pro",
price: annual ? 24 : 30,
features: [
"Unlimited projects",
"10 GB storage",
"Priority support",
"API access",
],
},
{
name: "Enterprise",
price: annual ? 80 : 100,
features: [
"Everything in Pro",
"SSO",
"Dedicated account manager",
"Custom integrations",
],
},
].map((tier) => (
<div key={tier.name} className="rounded border p-6">
<h3 className="text-lg font-bold">{tier.name}</h3>
<p className="mt-2 text-3xl font-bold">${tier.price}/mo</p>
<ul className="mt-4 space-y-2">
{tier.features.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
<button className="mt-6 w-full rounded bg-blue-600 py-2 text-white">
Get Started
</button>
</div>
))}
</div>
</section>
```
Count the problems.
## Layer 1: Copywriting
### Fix the tier names
**Before:** Basic / Pro / Enterprise
**After:** Starter / Team / Organization
### Fix the tier descriptions
**Before:** "5 projects, 1 GB storage, Email support"
**After:** "For solo makers shipping side projects" / "For teams shipping daily" / "For companies that need compliance and control"
Describes the buyer, not the product.
### Fix the CTAs
**Before:** Get Started / Get Started / Get Started
**After:** Start for free / Start a team trial / Talk to sales
### Fix the section header
**Before:** "Choose the plan that's right for you."
**After:** "Start free. Scale when you're ready."
## Layer 2: Typography
### Tabular numerals
```tsx
<p className="mt-2 font-semibold text-4xl tracking-tight tabular-nums">
${tier.price}
<span className="text-muted-foreground text-base font-normal tracking-normal">
/mo
</span>
</p>
```
### Tier label letterspacing
```tsx
<p className="text-sm font-medium tracking-widest uppercase text-muted-foreground">
{tier.label}
</p>
<h3 className="mt-1 text-lg font-semibold">{tier.name}</h3>
```
### Hierarchy between tiers
```tsx
{/* Standard tier */}
<p className="text-4xl font-semibold tracking-tight tabular-nums">
{/* Recommended tier */}
<p className="text-5xl font-semibold tracking-tight tabular-nums">
```
### Proper punctuation
```tsx
<span className="text-muted-foreground text-base font-normal">
 / mo
</span>
```
## Layer 3: Craft
### The toggle
```tsx
<div className="inline-flex items-center gap-3 rounded-full bg-muted p-1">
<button
onClick={() => setAnnual(false)}
className={cn(
"rounded-full px-4 py-2 text-sm font-medium transition-colors",
!annual
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground"
)}
>
Monthly
</button>
<button
onClick={() => setAnnual(true)}
className={cn(
"rounded-full px-4 py-2 text-sm font-medium transition-colors",
annual
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground"
)}
>
Annual
</button>
</div>
```
### The recommended tier
```tsx
<div
className={cn(
"relative rounded-2xl p-8",
tier.recommended
? "border-2 border-primary bg-primary/[0.03] shadow-lg"
: "border border-border"
)}
>
{tier.recommended && (
<p className="absolute -top-3 left-6 bg-primary px-3 py-0.5 text-xs font-semibold text-primary-foreground rounded-full">
Most popular
</p>
)}
```
### CTA differentiation
```tsx
<a
href={tier.href}
className={cn(
"mt-8 flex h-11 items-center justify-center rounded-lg text-sm font-medium transition-colors",
tier.recommended
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "border border-border text-foreground hover:bg-muted"
)}
>
{tier.cta}
</a>
```
`<a>`, not `<button>`. `h-11` for 44px touch target.
### Responsive layout
```tsx
<div className="mx-auto mt-12 grid max-w-sm grid-cols-1 gap-6 sm:max-w-none sm:grid-cols-3">
```
## Layer 4: Animation
### Toggle price transition
```tsx
<AnimatePresence mode="wait">
<motion.span
key={annual ? "annual" : "monthly"}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="tabular-nums"
>
${tier.price}
</motion.span>
</AnimatePresence>
```
### Card hover states
```tsx
<motion.div
whileHover={{ y: -2 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className={cn("rounded-2xl p-8", tier.recommended ? "..." : "...")}
>
```
### Reduced motion
```tsx
const prefersReduced = useReducedMotion();
<AnimatePresence mode="wait">
<motion.span
key={annual ? "annual" : "monthly"}
initial={prefersReduced ? false : { opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReduced ? { opacity: 0 } : { opacity: 0, y: 10 }}
transition={prefersReduced ? { duration: 0.15 } : { type: "spring", duration: 0.3, bounce: 0 }}
>
```
Reduced motion keeps the crossfade but removes vertical translation.