Pricing Table React Component: 3-Tier, Annual Toggle, Highlight
Build a production-ready 3-tier pricing table in React with Tailwind CSS, annual/monthly toggle, and a highlighted recommended plan — no UI library needed.
Why Pricing Tables Are Harder Than They Look
Every SaaS needs one. Most teams build it in an afternoon, ship it, then spend the next six months patching edge cases — the toggle that doesn't animate, the highlight ring that clips on mobile, the annual discount that's hardcoded in three places. Sound familiar?
Honestly, a pricing table is one of those components where the first version takes two hours and the polished version takes two days. The tricky bits aren't the layout. They're the state (monthly vs. annual), the visual hierarchy between tiers, and the highlighted card not looking like you just slapped a border: 2px solid purple on it.
This guide walks you through a real, production-grade approach — three tiers, an animated billing-period toggle, and a proper 'Most Popular' highlight. We're using React 18 and Tailwind CSS 3.4. You can drop this straight into a Next.js 14 app with zero modifications.
Worth noting: the pattern here also works well alongside design systems like Empire UI. If you're already using its card primitives or badge components, you can slot this pricing table structure in without fighting a second design language.
Setting Up the Data Model
Before touching JSX, get your data structure right. You'll thank yourself later when marketing wants to A/B test price points without you touching component logic.
Define a plans array outside the component. Each plan has a monthly and annual price, a features list, and a highlighted boolean. That's it. No nested config objects, no context magic, no Redux slice for a pricing table.
// plans.js
export const plans = [
{
id: 'starter',
name: 'Starter',
monthly: 9,
annual: 7,
description: 'Perfect for indie projects and side hustles.',
features: ['3 projects', '5GB storage', 'Email support'],
highlighted: false,
cta: 'Get started',
},
{
id: 'pro',
name: 'Pro',
monthly: 29,
annual: 23,
description: 'For teams shipping product fast.',
features: ['Unlimited projects', '50GB storage', 'Priority support', 'API access'],
highlighted: true,
cta: 'Start free trial',
},
{
id: 'enterprise',
name: 'Enterprise',
monthly: 99,
annual: 79,
description: 'For orgs that need SLAs and control.',
features: ['Everything in Pro', 'SSO / SAML', 'Dedicated success manager', 'Custom contracts'],
highlighted: false,
cta: 'Contact sales',
},
];Keep the annual price as the per-month equivalent, not the yearly total. Showing $79/mo instead of $948/yr is what every SaaS does, and it's what users expect to see. Quick aside: if you need to show the yearly total as well, compute it in the render layer — don't store it in your data.
Building the Billing Toggle
The toggle is the first thing you should build, because everything else depends on the isAnnual state. Keep it in the parent PricingTable component and pass it down. No context needed at this scale.
The animation is a simple transition-transform on a sliding pill inside a rounded track. 44px wide for the track, 20px for the pill — those numbers give you the thumb feel of a real toggle without importing a library.
// PricingToggle.jsx
export function PricingToggle({ isAnnual, onChange }) {
return (
<div className="flex items-center gap-3 text-sm font-medium">
<span className={isAnnual ? 'text-gray-400' : 'text-gray-900 dark:text-white'}>
Monthly
</span>
<button
role="switch"
aria-checked={isAnnual}
onClick={() => onChange(!isAnnual)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 ${
isAnnual ? 'bg-violet-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-md transition-transform ${
isAnnual ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<span className={isAnnual ? 'text-gray-900 dark:text-white' : 'text-gray-400'}>
Annual
<span className="ml-1.5 rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700">
Save 20%
</span>
</span>
</div>
);
}That role="switch" and aria-checked isn't optional fluff — screen readers announce toggle state with those attributes. Skipping them in 2026 on a public-facing page is a hard fail on any accessibility audit.
In practice, the green 'Save 20%' badge is the highest-converting copy you'll put on this page. Don't overthink the wording. 'Save 20%' outperforms 'Best value', 'Annual deal', and every creative variant in almost every test.
The 3-Tier Card Layout With Highlight
Here's where most implementations go wrong. They try to make the highlighted card taller, add absolute-positioned badges, and end up with a layout that breaks at 768px. The cleaner approach: keep all cards the same height, use a ring and scale transform, and float the badge inside the card header.
// PricingCard.jsx
export function PricingCard({ plan, isAnnual }) {
const price = isAnnual ? plan.annual : plan.monthly;
return (
<div
className={`relative flex flex-col rounded-2xl p-8 transition-all ${
plan.highlighted
? 'bg-violet-600 text-white ring-4 ring-violet-400 ring-offset-2 scale-105 shadow-2xl'
: 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-800'
}`}
>
{plan.highlighted && (
<span className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-amber-400 px-4 py-1 text-xs font-bold text-gray-900 shadow">
Most Popular
</span>
)}
<div className="mb-6">
<h3 className="text-lg font-bold">{plan.name}</h3>
<p className={`mt-1 text-sm ${ plan.highlighted ? 'text-violet-200' : 'text-gray-500 dark:text-gray-400'}`}>
{plan.description}
</p>
</div>
<div className="mb-6">
<span className="text-5xl font-extrabold">${price}</span>
<span className={`ml-1 text-sm ${ plan.highlighted ? 'text-violet-200' : 'text-gray-500'}`}>/mo</span>
{isAnnual && (
<p className={`mt-1 text-xs ${ plan.highlighted ? 'text-violet-200' : 'text-gray-400'}`}>
billed annually
</p>
)}
</div>
<ul className="mb-8 flex-1 space-y-3">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<svg className="h-4 w-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414L8.414 15l-4.121-4.121a1 1 0 011.414-1.414L8.414 12.172l6.879-6.879a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
{f}
</li>
))}
</ul>
<button
className={`w-full rounded-xl py-3 text-sm font-semibold transition-colors ${
plan.highlighted
? 'bg-white text-violet-700 hover:bg-violet-50'
: 'bg-violet-600 text-white hover:bg-violet-700'
}`}
>
{plan.cta}
</button>
</div>
);
}The scale-105 on the highlighted card is doing a lot of heavy lifting visually. It's subtle — only 5% larger — but it creates a clear focal point without the card looking absurdly oversized. Pair it with the violet ring and you've got three layers of hierarchy communicating 'this is the one' without a single word.
That said, scale-105 breaks grid alignment in some edge cases. Wrap the cards in a container with items-center on a flex row, not items-stretch, or you'll get height mismatches on the flanking cards.
For the responsive layout, go grid-cols-1 md:grid-cols-3 on the container. On mobile, a single column with all three stacked reads cleanly — users scroll, compare, decide. Don't try to do a horizontal scroll carousel on mobile. Nobody swipes through pricing cards.
Wiring It All Together
The parent PricingTable component is almost embarrassingly simple. One piece of state, the toggle, and a map over your plans array. That's the whole thing.
// PricingTable.jsx
import { useState } from 'react';
import { plans } from './plans';
import { PricingToggle } from './PricingToggle';
import { PricingCard } from './PricingCard';
export function PricingTable() {
const [isAnnual, setIsAnnual] = useState(false);
return (
<section className="py-20 px-4">
<div className="mx-auto max-w-5xl">
<div className="mb-12 text-center">
<h2 className="text-4xl font-extrabold tracking-tight">Simple, honest pricing</h2>
<p className="mt-3 text-gray-500 dark:text-gray-400">
No hidden fees. Cancel any time.
</p>
<div className="mt-6 flex justify-center">
<PricingToggle isAnnual={isAnnual} onChange={setIsAnnual} />
</div>
</div>
<div className="grid grid-cols-1 items-center gap-6 md:grid-cols-3">
{plans.map((plan) => (
<PricingCard key={plan.id} plan={plan} isAnnual={isAnnual} />
))}
</div>
</div>
</section>
);
}Look, the entire interactive component is under 120 lines across three files. If yours is longer than that, you're storing things in state that belong in config, or styling things inline that belong in class variants.
One more thing — add a key={isAnnual ? 'annual' : 'monthly'} to the price span if you want a quick fade/slide animation when prices swap. React will remount the element on key change, which you can intercept with a CSS animation. No framer-motion needed for this particular effect.
Accessibility and Dark Mode Checklist
Before you ship, run through this quickly. The toggle needs role="switch" and aria-checked. Card CTAs need visible focus rings — Tailwind's focus-visible:ring-2 handles that. The price doesn't need to be a heading, but if you make it one, keep it <p> or <span> to avoid messing up your document outline.
Dark mode is already in the snippet above via dark: variants. If your app uses class strategy in tailwind.config.js, you're done. If you're on media strategy, drop the explicit dark: classes — the system preference handles it automatically.
Color contrast on the highlighted violet card is worth double-checking. #7C3AED (violet-600) against white text passes WCAG AA. If you swap in a custom brand color, run it through the box shadow generator or any contrast checker before deploying — lighter purples frequently fail at normal text sizes.
Quick aside: the amber badge ('Most Popular') is yellow text on yellow background by default if you're not careful. The text-gray-900 in the snippet is intentional. Don't change it to text-white — it'll fail contrast at 12px font size.
Extending the Component for Real SaaS Products
What you've built so far handles 80% of use cases. Here's what the next 20% usually needs: a feature comparison table below the cards, a 'Contact sales' flow that opens a modal instead of routing, and per-plan feature availability flags (not just lists — think checkmarks vs. dashes).
For the feature comparison table, render a separate <PricingComparison> component below the card grid. Keep it opt-in — don't fold it into the card layout. Users who've already decided on a plan don't need to scroll through 40 rows of feature flags.
If you want to get more adventurous with the visual style — frosted glass cards, gradient rings on the highlighted tier, animated gradient backgrounds — check out the glassmorphism components section for ready-made patterns that pair directly with this layout. The card primitives there will drop into the PricingCard shell without a complete rewrite.
For teams that want even more pre-built SaaS page patterns without starting from scratch, browse the components to see what's available — there are landing page templates that already include a pricing section wired up to this exact three-tier pattern, tested across breakpoints.
FAQ
Add a key prop to the price element tied to isAnnual — React remounts it on key change and you can trigger a CSS fade-in animation on the element. No animation library needed for this.
Yes — swap the static plans array for data fetched from your Stripe Products API or a CMS. Pass a priceId on each plan object and send it to your checkout endpoint when the CTA is clicked.
A taller card breaks grid alignment and forces awkward padding math on adjacent cards. Scale transform keeps the grid intact and the visual emphasis reads clearly without layout side effects.
Set both monthly and annual to 0 in your plans data and conditionally render 'Free' instead of '$0' in the price display. Keep the CTA as 'Get started' — 'Sign up free' converts slightly worse in most tests.