Subscription Pricing UI: Monthly/Annual Toggle, Feature Comparison
Build a conversion-optimized subscription pricing UI with a monthly/annual toggle and feature comparison table — real React code, zero fluff.
Why Pricing UI Is Harder Than It Looks
Pricing pages are the highest-stakes screen in any SaaS product. One confusing label, one misaligned column, one toggle that's too subtle — and the user bounces. You're not just making things pretty here; you're making a revenue-critical decision tree legible in about 4 seconds.
Honestly, most pricing UIs are terrible. They either drown you in feature rows nobody reads, or they strip so much away the plans feel identical. The sweet spot is a tight, scannable layout with a clear toggle between billing periods, a visually distinct 'recommended' tier, and a feature matrix that's honest about what each plan actually does.
In practice, the monthly/annual toggle is where most teams slip up. They treat it as an afterthought — a tiny switch above three cards — and then wonder why annual conversion rates stay flat. The toggle needs to show the savings clearly, switch prices instantly with no layout shift, and persist the user's choice if they navigate away and return. That's three separate implementation concerns, and we'll cover all of them.
This article walks you through the complete React implementation: state management for the toggle, price calculation logic, the comparison table, and the visual design. We'll use Tailwind CSS v3.4+ throughout. No extra libraries required unless you want them.
Data Model: Structuring Your Plans
Before you write a single JSX line, nail your data shape. A well-typed plan object makes the toggle and comparison table almost write themselves. Here's the TypeScript interface you want:
// types/pricing.ts
export type BillingPeriod = 'monthly' | 'annual';
export interface PlanFeature {
label: string;
tooltip?: string;
tiers: {
starter: boolean | string;
pro: boolean | string;
enterprise: boolean | string;
};
}
export interface PricingPlan {
id: 'starter' | 'pro' | 'enterprise';
name: string;
description: string;
monthlyPrice: number; // in USD cents
annualPrice: number; // per month when billed annually, in cents
highlighted: boolean;
cta: string;
href: string;
}The key decision is storing annualPrice as the per-month equivalent, not the annual lump sum. Your toggle will display $X/mo in both states, which is far less psychologically jarring than jumping between $15/mo and $144/yr. You surface the lump sum only at checkout. That's the pattern Stripe Billing recommends and the one users expect in 2026.
Worth noting: keep monthlyPrice and annualPrice in cents (integers). Floating-point math on displayed prices is a classic source of $14.999999 bugs. Divide by 100 only at render time.
// data/plans.ts
export const PLANS: PricingPlan[] = [
{
id: 'starter',
name: 'Starter',
description: 'For indie hackers and side projects.',
monthlyPrice: 1500, // $15/mo
annualPrice: 1000, // $10/mo (billed $120/yr)
highlighted: false,
cta: 'Start free trial',
href: '/signup?plan=starter',
},
{
id: 'pro',
name: 'Pro',
description: 'For small teams shipping fast.',
monthlyPrice: 4900, // $49/mo
annualPrice: 3900, // $39/mo (billed $468/yr)
highlighted: true,
cta: 'Start free trial',
href: '/signup?plan=pro',
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'Custom seats, SSO, SLA.',
monthlyPrice: 0, // custom pricing
annualPrice: 0,
highlighted: false,
cta: 'Talk to sales',
href: '/contact',
},
];The Monthly/Annual Toggle Component
The toggle is a controlled input that lives at the parent level and passes billing down as a prop. Don't reach for a global store for this — it's local UI state, full stop. Here's the minimal toggle component:
// components/BillingToggle.tsx
import { BillingPeriod } from '@/types/pricing';
interface BillingToggleProps {
value: BillingPeriod;
onChange: (v: BillingPeriod) => void;
}
export function BillingToggle({ value, onChange }: BillingToggleProps) {
const isAnnual = value === 'annual';
return (
<div className="flex items-center gap-3 justify-center">
<button
onClick={() => onChange('monthly')}
className={`text-sm font-medium transition-colors ${
!isAnnual ? 'text-white' : 'text-white/50'
}`}
>
Monthly
</button>
{/* pill toggle */}
<button
role="switch"
aria-checked={isAnnual}
onClick={() => onChange(isAnnual ? 'monthly' : 'annual')}
className={`relative w-12 h-6 rounded-full transition-colors ${
isAnnual ? 'bg-violet-500' : 'bg-white/20'
}`}
>
<span
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
isAnnual ? 'translate-x-6' : 'translate-x-0'
}`}
/>
</button>
<button
onClick={() => onChange('annual')}
className={`text-sm font-medium transition-colors ${
isAnnual ? 'text-white' : 'text-white/50'
}`}
>
Annual
{isAnnual && (
<span className="ml-2 text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
Save 20%
</span>
)}
</button>
</div>
);
}The role="switch" and aria-checked attributes are not optional here. Screen readers need to announce the toggle state, and without them you're failing WCAG 2.1 SC 4.1.2. Quick aside: the 'Save 20%' badge should only appear when annual is active — showing it constantly turns it into wallpaper users ignore.
Notice there's no animation on the price numbers themselves. Don't add a number-counting animation to prices. It reads as playful in a context where users are making a financial decision, and it delays the scan. The thumb sliding across the pill is enough motion to signal that something changed.
The parent wires it up with a single useState:
``tsx
const [billing, setBilling] = useState<BillingPeriod>('monthly');
`
If you want to persist the preference across page visits, swap in useLocalStorage from usehooks-ts`. Takes about 30 seconds.
Pricing Card Layout
Three cards in a row, the middle one highlighted. That layout has dominated SaaS pricing since at least 2015 and it still converts because it makes the recommended option visually obvious without being pushy. Here's the card component:
// components/PricingCard.tsx
import { PricingPlan, BillingPeriod } from '@/types/pricing';
interface PricingCardProps {
plan: PricingPlan;
billing: BillingPeriod;
}
function formatPrice(cents: number): string {
if (cents === 0) return 'Custom';
return `$${(cents / 100).toFixed(0)}`;
}
export function PricingCard({ plan, billing }: PricingCardProps) {
const price = billing === 'annual' ? plan.annualPrice : plan.monthlyPrice;
const isHighlighted = plan.highlighted;
return (
<div
className={[
'relative flex flex-col rounded-2xl p-8 transition-all',
isHighlighted
? 'bg-violet-600 shadow-2xl shadow-violet-500/30 scale-105'
: 'bg-white/5 backdrop-blur-sm border border-white/10',
].join(' ')}
>
{isHighlighted && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 bg-gradient-to-r from-violet-400 to-pink-400 text-white text-xs font-semibold px-4 py-1 rounded-full">
Most Popular
</div>
)}
<h3 className="text-lg font-semibold text-white">{plan.name}</h3>
<p className="mt-1 text-sm text-white/60">{plan.description}</p>
<div className="mt-6 flex items-end gap-1">
<span className="text-4xl font-bold text-white">
{formatPrice(price)}
</span>
{price > 0 && (
<span className="text-white/50 text-sm mb-1">/mo</span>
)}
</div>
{billing === 'annual' && price > 0 && (
<p className="mt-1 text-xs text-white/40">
Billed ${((price * 12) / 100).toFixed(0)}/year
</p>
)}
<a
href={plan.href}
className={[
'mt-8 block text-center py-3 rounded-xl text-sm font-semibold transition-all',
isHighlighted
? 'bg-white text-violet-700 hover:bg-white/90'
: 'bg-white/10 text-white hover:bg-white/20',
].join(' ')}
>
{plan.cta}
</a>
</div>
);
}The scale-105 on the highlighted card gives it vertical prominence without needing extra margin tricks. That said, on mobile you'll want to drop the scale transform and instead just add a more intense border — scale-105 in a stacked single-column layout just clips the card edges.
Look, the annual price disclosure — 'Billed $468/year' — is not a dark pattern. It's transparency. Users who make it to your pricing page have enough context to handle the math. Hiding the annual total until checkout creates support tickets.
You can style the cards with glassmorphism components from Empire UI if you want the frosted translucent look instead of the solid violet. The bg-white/5 backdrop-blur-sm border border-white/10 classes already push in that direction — swap the highlighted card's solid bg-violet-600 for bg-violet-500/30 backdrop-blur-md and you're in full glass territory. Head to the glassmorphism generator to fine-tune the blur and opacity values visually.
Feature Comparison Table
The toggle + cards above handle the top-of-page conversion moment. The comparison table handles the 'wait, does Pro include X?' moment. Get it wrong and users open a competitor tab. The table needs sticky column headers, clear checkmarks vs crosses, and tooltips on ambiguous features.
// components/FeatureTable.tsx
import { PlanFeature } from '@/types/pricing';
import { CheckIcon, XMarkIcon } from '@heroicons/react/20/solid';
const FEATURES: PlanFeature[] = [
{
label: 'Projects',
tiers: { starter: '3', pro: 'Unlimited', enterprise: 'Unlimited' },
},
{
label: 'Team members',
tiers: { starter: '1', pro: '10', enterprise: 'Unlimited' },
},
{
label: 'API access',
tiers: { starter: false, pro: true, enterprise: true },
},
{
label: 'SSO / SAML',
tiers: { starter: false, pro: false, enterprise: true },
},
{
label: 'Priority support',
tiers: { starter: false, pro: true, enterprise: true },
},
{
label: 'SLA guarantee',
tooltip: '99.9% uptime SLA with credits for downtime',
tiers: { starter: false, pro: false, enterprise: true },
},
];
type Tier = 'starter' | 'pro' | 'enterprise';
function Cell({ value }: { value: boolean | string }) {
if (typeof value === 'string') {
return <span className="text-sm text-white font-medium">{value}</span>;
}
return value
? <CheckIcon className="w-5 h-5 text-green-400 mx-auto" />
: <XMarkIcon className="w-5 h-5 text-white/20 mx-auto" />;
}
export function FeatureTable() {
const tiers: Tier[] = ['starter', 'pro', 'enterprise'];
return (
<div className="mt-16 overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/10">
<th className="py-4 pr-8 text-sm font-semibold text-white/50 w-1/2">Feature</th>
{tiers.map(tier => (
<th key={tier} className="py-4 px-4 text-sm font-semibold text-white capitalize text-center">
{tier}
</th>
))}
</tr>
</thead>
<tbody>
{FEATURES.map(feature => (
<tr key={feature.label} className="border-b border-white/5 hover:bg-white/[0.02]">
<td className="py-4 pr-8">
<span className="text-sm text-white/80">{feature.label}</span>
{feature.tooltip && (
<span className="ml-1 text-xs text-white/30" title={feature.tooltip}>(?)</span>
)}
</td>
{tiers.map(tier => (
<td key={tier} className="py-4 px-4 text-center">
<Cell value={feature.tiers[tier]} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}A few notes on the table design. The hover:bg-white/[0.02] row highlight is subtle on purpose — just enough to help users track horizontally without flashing. The overflow-x-auto wrapper on mobile is non-negotiable; at 375px a 4-column table otherwise becomes completely unreadable.
In practice, the tooltip for ambiguous features like 'SLA guarantee' saves more support tickets than any FAQ section. The title attribute is the lowest-effort implementation; for production you'd swap it with a Radix Tooltip component to get keyboard accessibility and consistent styling. Empire UI ships a tooltip component you can drop in directly.
One more thing — don't put pricing table rows for features the Starter tier simply doesn't have. A long column of X marks trains users to read your table as a list of limitations rather than a list of value. Lead with the features all plans share, then layer on the differentiators.
Putting It Together: The PricingSection
The parent component owns the billing state and passes it down. Keep this file thin — orchestration only, no styling logic here.
// components/PricingSection.tsx
'use client';
import { useState } from 'react';
import { BillingPeriod } from '@/types/pricing';
import { BillingToggle } from './BillingToggle';
import { PricingCard } from './PricingCard';
import { FeatureTable } from './FeatureTable';
import { PLANS } from '@/data/plans';
export function PricingSection() {
const [billing, setBilling] = useState<BillingPeriod>('monthly');
return (
<section className="py-24 px-4">
<div className="max-w-5xl mx-auto">
<h2 className="text-4xl font-bold text-white text-center">
Simple, transparent pricing
</h2>
<p className="mt-4 text-white/60 text-center max-w-xl mx-auto">
Start free. Upgrade when you need to. No contracts, cancel anytime.
</p>
<div className="mt-10">
<BillingToggle value={billing} onChange={setBilling} />
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
{PLANS.map(plan => (
<PricingCard key={plan.id} plan={plan} billing={billing} />
))}
</div>
<FeatureTable />
</div>
</section>
);
}That's it. The whole thing is about 180 lines across four files. No state management library, no third-party pricing widget, no proprietary component kit. Just typed data, controlled state, and Tailwind.
For the background, pair this with a deep gradient or an aurora effect. Something like bg-gradient-to-br from-slate-900 via-violet-950 to-slate-900 on the <section> gives the cards something dark and rich to sit against. If you want to go further, the gradient generator lets you dial in exact stops and copy the CSS in one click — no guessing required.
You can also lift the entire visual style into any of Empire UI's design themes. Drop the cards into the neobrutalism style if you're building a bold, high-contrast product page, or keep the translucent glass look for a more premium SaaS feel. The component structure stays identical — only the Tailwind classes change.
Conversion Patterns Worth Stealing
The code is the easy part. What actually moves annual conversion numbers is the copy and the visual hierarchy around the toggle. Here are the patterns that consistently work, drawn from A/B test results published by teams like Lemon Squeezy, Paddle, and Stripe in 2025 and 2026.
First, default to annual. Pre-selecting the annual toggle increases annual plan attach rate by roughly 15–30% in most reported experiments, because users who don't actively choose a billing period end up on the default. The monthly option is still one click away — you're not trapping anyone.
Second, show the monthly-equivalent price even in annual mode. Displaying $39/mo instead of $468/yr reduces sticker shock by anchoring on a smaller number. You still disclose the annual total (it's right there below the price in our PricingCard implementation), but the headline number matches what users expect from a monthly framing.
Third, make the savings concrete and specific. 'Save 20%' is good. 'Save $120/year' is better in many contexts because it's a tangible dollar amount, not a relative percentage. Which works better depends on your price point — at $10/mo the dollar amount is too small to impress; at $49/mo it's meaningful. Test both. That said, you don't need a full A/B testing framework to validate this — even a simple URL param approach (?billing=annual) and two weeks of Google Analytics data will tell you which framing your users respond to.
Finally, if you're using a visual style system, check that your pricing cards are visually distinct from the rest of your page. The highlighted card should be the single most visually prominent element on the screen — not competing with a hero banner, a navigation announcement bar, or an animated background. Empire UI's templates include SaaS pricing page layouts where this visual hierarchy is already calibrated, so you can start from a known-good baseline rather than tuning from scratch.
FAQ
Default to annual if your annual discount is 15% or more — most teams that have tested this see meaningful improvements in annual attach rate. If your discount is minimal or your users are cost-sensitive early adopters, monthly is safer as the default since it shows the lower commitment.
Don't animate the numbers themselves — it adds delay to a decision the user is actively making. The toggle thumb sliding is enough visual feedback. If you want extra polish, a quick opacity-0 → opacity-100 crossfade on the price element (around 150ms) works without feeling slow or distracting.
Below the pricing cards, not above them. Users make the emotional decision at the cards level and use the table to validate. Leading with a dense feature matrix before prices are anchored causes confusion. Keep the table collapsible on mobile to avoid a wall of rows.
Use localStorage via a useLocalStorage hook (usehooks-ts is a good lightweight option). Persist the key billing-period with a value of monthly or annual. On mount, read it back and initialize state from it. This is especially useful if you link to your pricing page from a checkout confirmation or upsell modal.