Tailwind Pricing Section: 3-Tier Layout with Annual Toggle
Build a production-ready 3-tier pricing section in Tailwind CSS with a monthly/annual toggle, highlighted plan, and React state — copy-paste ready.
Why Pricing Sections Are Worth Getting Right
A pricing section is the closest thing to a checkout button that lives on your marketing page. It's the moment users decide whether to pull out a card or bounce — and yet most implementations are slapped together in an afternoon. Bad idea. The layout, the toggle, the highlighted tier — each of those details moves conversion numbers, and if you're building a SaaS product in 2026 you probably already know A/B tests on pricing pages outperform tests on almost any other part of the site.
That said, this article isn't about pricing psychology. It's about building the thing correctly in Tailwind CSS and React so you're not wrestling with broken grid gaps or a toggle that jumps around when the price text changes length. We'll do a real 3-column layout, a monthly/annual switch that saves users money, and a visually distinct 'recommended' middle tier.
Honestly, most pricing tutorials skip the hard part: making the card heights equal across tiers when one card has more feature bullets than the others. We'll handle that with a proper flex column layout so your CTA button always anchors to the bottom of the card — because a 'Get started' button floating halfway up a card looks unfinished.
One more thing — if you're looking for a full design system to drop into your project rather than hand-rolling every component, check out Empire UI. The pre-built templates include several production pricing layouts you can adapt in minutes instead of hours.
The HTML Structure: 3-Column Grid with Feature Lists
Start with a container that centers the section and caps its width. Inside you'll have a heading block, the toggle, and a 3-column grid. The grid itself is the easy part — grid grid-cols-1 md:grid-cols-3 gap-6 — but the card internals need care if you want uniform heights.
Each card should be a flex column with flex flex-col h-full. The feature list gets flex-1 so it expands to fill available space, and the CTA button sits at the bottom with mt-auto. Without that pattern you'll fight card heights every time a feature bullet wraps to a second line.
Here's the base card shell:
``tsx
function PricingCard({ plan, isAnnual, isHighlighted }) {
const price = isAnnual ? plan.annualPrice : plan.monthlyPrice;
return (
<div
className={[
'flex flex-col rounded-2xl p-8 border',
isHighlighted
? 'bg-indigo-600 border-indigo-500 text-white shadow-2xl scale-[1.03]'
: 'bg-white border-gray-200 text-gray-900',
].join(' ')}
>
<div className="mb-6">
<p className="text-sm font-semibold uppercase tracking-wide opacity-70">
{plan.name}
</p>
<p className="mt-2 text-5xl font-bold">
${price}
<span className="text-lg font-normal opacity-60">/mo</span>
</p>
{isAnnual && (
<p className="mt-1 text-sm opacity-70">Billed annually</p>
)}
</div>
<ul className="flex-1 space-y-3 mb-8">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm">
<span className="mt-0.5 text-green-400">✓</span>
{f}
</li>
))}
</ul>
<button
className={[
'mt-auto w-full rounded-xl py-3 font-semibold transition',
isHighlighted
? 'bg-white text-indigo-600 hover:bg-indigo-50'
: 'bg-indigo-600 text-white hover:bg-indigo-700',
].join(' ')}
>
{plan.cta}
</button>
</div>
);
}
``
Notice the scale-[1.03] on the highlighted card. That 3% scale lift is enough to visually pop the recommended tier without the dramatic oversizing you see on some sites. You can get more creative with the highlighted treatment — a dark card, a gradient, a glow border — but the scale trick requires zero extra HTML and reads as 'this is the one we want you to pick' instantly.
Worth noting: the feature check marks use a text-green-400 span rather than an SVG icon. That's intentional — fewer DOM nodes, zero icon dependency. If you want a more polished checkmark, swap in a Heroicons CheckIcon component, but don't let icon fatigue block you from shipping.
Building the Monthly/Annual Toggle
The toggle is a single boolean in React state. Everything downstream — prices, billing labels, savings badges — derives from that one value. Don't overthink the state shape.
import { useState } from 'react';
export function PricingSection() {
const [isAnnual, setIsAnnual] = useState(false);
return (
<section className="py-24 bg-gray-50">
<div className="mx-auto max-w-6xl px-4">
<h2 className="text-center text-4xl font-bold text-gray-900">
Simple, transparent pricing
</h2>
<p className="mt-4 text-center text-gray-500">
No hidden fees. Cancel any time.
</p>
{/* Toggle */}
<div className="mt-10 flex items-center justify-center gap-4">
<span className={`text-sm font-medium ${
!isAnnual ? 'text-gray-900' : 'text-gray-400'
}`}>Monthly</span>
<button
onClick={() => setIsAnnual((v) => !v)}
className={[
'relative inline-flex h-7 w-14 rounded-full transition-colors duration-200',
isAnnual ? 'bg-indigo-600' : 'bg-gray-300',
].join(' ')}
role="switch"
aria-checked={isAnnual}
>
<span
className={[
'absolute top-1 left-1 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200',
isAnnual ? 'translate-x-7' : 'translate-x-0',
].join(' ')}
/>
</button>
<span className={`text-sm font-medium ${
isAnnual ? 'text-gray-900' : 'text-gray-400'
}`}>
Annual
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700">
Save 20%
</span>
</span>
</div>
{/* Cards */}
<div className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
{plans.map((plan) => (
<PricingCard
key={plan.name}
plan={plan}
isAnnual={isAnnual}
isHighlighted={plan.highlighted}
/>
))}
</div>
</div>
</section>
);
}The toggle itself is a custom <button> with role="switch" and aria-checked — that's the accessible pattern per ARIA 1.2. Don't use a checkbox here; a visible pill toggle maps semantically to a switch, not a checkbox.
In practice, the 'Save 20%' badge on the Annual label is one of the highest-impact micro-copy decisions on this whole component. Users' eyes land on the toggle, see the green pill, and the value proposition is immediate. You can compute the savings dynamically from your plan data rather than hardcoding 20%, which keeps the badge honest if you change pricing later.
Quick aside: if you're animating the price change (fade-out old number, fade-in new), you'll want a key on the price element tied to the isAnnual value so React remounts it and your CSS animation triggers. Something like <p key={isAnnual ? 'annual' : 'monthly'} className="animate-fade-in"> — add a 200ms fade with a custom Tailwind keyframe and it feels polished without being distracting.
The Plans Data Shape and Price Math
Keep your plan data in a typed structure outside the component. You want to be able to change prices in one place and have the UI respond everywhere — including the badge, the card, and any comparison table you might add later.
interface Plan {
name: string;
monthlyPrice: number;
annualPrice: number; // per-month equivalent when billed annually
features: string[];
cta: string;
highlighted?: boolean;
}
export const plans: Plan[] = [
{
name: 'Starter',
monthlyPrice: 0,
annualPrice: 0,
features: [
'3 projects',
'1 seat',
'Community support',
'5 GB storage',
],
cta: 'Get started free',
},
{
name: 'Pro',
monthlyPrice: 29,
annualPrice: 23, // 29 * 0.8 ≈ 23, billed as 276/yr
features: [
'Unlimited projects',
'5 seats',
'Priority support',
'100 GB storage',
'Custom domains',
'Analytics dashboard',
],
cta: 'Start free trial',
highlighted: true,
},
{
name: 'Enterprise',
monthlyPrice: 99,
annualPrice: 79,
features: [
'Unlimited projects',
'Unlimited seats',
'Dedicated support',
'2 TB storage',
'SSO + SAML',
'SLA 99.9%',
'Audit logs',
],
cta: 'Contact sales',
},
];That annualPrice field is the *per-month equivalent* when billing annually — which is the number you display on the card. You separately compute the annual total (annualPrice * 12) when you generate the invoice or the Stripe Price ID. Keep those concerns separate; this component doesn't need to know about Stripe.
Look, you might be tempted to store prices in cents and format them yourself. That's fine for a payment library but overkill in a display component. These are small dollar amounts shown to humans, not floating-point calculations going to a payment processor. Just use integers and a $ prefix.
Also define a SAVINGS_PERCENT constant computed from your data rather than hardcoded: Math.round((1 - plan.annualPrice / plan.monthlyPrice) * 100). That way the green badge always shows the real discount, and your marketing team can update annualPrice in the data file without touching the component.
Responsive Polish and Dark Mode
On mobile the 3-column grid stacks to 1 column, which is fine — but your highlighted card no longer benefits from the scale-[1.03] trick when it's stacked vertically. Add a top border accent instead for mobile: border-t-4 border-indigo-500 on the highlighted card. That keeps it visually distinct even at 375px wide.
Dark mode is where a lot of pricing sections fall apart. If you're using class-based dark mode in Tailwind, you need to explicitly handle each surface. The plain white card becomes bg-white dark:bg-gray-800, border becomes border-gray-200 dark:border-gray-700, and body text becomes text-gray-900 dark:text-gray-100. The highlighted indigo card actually works in both modes without changes since it's a solid color — which is a nice side effect of the design choice.
// Dark-mode aware non-highlighted card
<div
className="flex flex-col rounded-2xl p-8 border
bg-white dark:bg-gray-800
border-gray-200 dark:border-gray-700
text-gray-900 dark:text-gray-100"
>For the toggle in dark mode, swap bg-gray-300 to bg-gray-600 in the off state so the thumb stays visible. A white thumb on a gray-300 background in dark mode blends into the page background — 1px of contrast difference makes it look broken. Small detail, ships real bugs.
If you want a more visually expressive pricing section — say, one with a glassmorphism card treatment or a gradient background — the glassmorphism generator can generate the exact Tailwind classes you need for a frosted-glass highlighted tier. It's a nicer visual treatment than a flat indigo card if your brand palette runs dark.
Animating Price Transitions Smoothly
When the user flips the toggle, the price numbers change. Without animation, it's a jarring jump. With a bad animation, it's distracting. The sweet spot is a 150ms cross-fade — fast enough to feel instant, slow enough to register as intentional.
Add a custom keyframe in your tailwind.config.js:
``js
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.15s ease-out',
},
},
},
};
`
Then on the price element, key it to the billing period:
`tsx
<p
key={${plan.name}-${isAnnual}}
className="mt-2 text-5xl font-bold animate-fade-in"
>
${price}
</p>
``
The key change forces React to unmount and remount the element, which restarts the CSS animation. It's a clean trick that avoids useEffect or any animation library. Worth noting: this only works if animate-fade-in is defined as a one-shot animation (no infinite), which it is in the config above.
If you want something fancier — numbers that count up or tick through intermediate values — you'd reach for a library like react-spring or framer-motion. But for a pricing toggle, the fade is enough. Keep it simple. Users notice when the price updates; they don't need a slot machine.
Dropping It into a Next.js App Router Page
If you're building this in Next.js 14+ with the App Router, the PricingSection component needs to be a Client Component because it uses useState. Add 'use client' at the top of the file and import it into your server-rendered page. The page itself stays a Server Component — no changes needed there.
// app/pricing/page.tsx (Server Component)
import { PricingSection } from '@/components/PricingSection';
export const metadata = {
title: 'Pricing | YourApp',
description: 'Simple pricing for teams of all sizes.',
};
export default function PricingPage() {
return (
<main>
<PricingSection />
</main>
);
}
```
```tsx
// components/PricingSection.tsx (Client Component)
'use client';
import { useState } from 'react';
// ... rest of the componentOne common mistake: putting the entire page in a Client Component because the toggle needs state. You don't need to do that. The data fetching, metadata, and layout all stay on the server. Only the interactive pricing island is a client component. That keeps your Time to First Byte down and your Lighthouse scores happy.
If you're pulling plan data from a CMS or a database, fetch it in the Server Component and pass it as props to PricingSection. That way the price data never hits the client bundle — it's baked into the HTML at render time, which is a meaningful SEO and performance win for a pricing page.
Once you have the pricing section working, you might want to wire up the CTA buttons to Stripe Checkout. That's a separate topic, but the pattern is: each plan gets a priceId field in the data object, the CTA button calls a Server Action or API route with that ID, and Stripe redirects the user to checkout. Nothing about this component needs to change — the data shape already has room for it. Check out the Empire UI templates for a full Next.js + Stripe starter that connects the dots.
FAQ
Conditionally render 'Free' instead of '$0' when the price is zero. A simple {price === 0 ? 'Free' : $${price}} in your JSX keeps it clean without any extra data fields.
Default to monthly. Users feel anchored to the lower-commitment option, and switching to annual feels like their decision — which makes the 'Save 20%' badge do its job. Defaulting to annual can feel pushy and erodes trust.
Give the card flex flex-col h-full, the feature list flex-1, and the CTA button mt-auto. The flexbox column stretches the feature list to fill available space so the button always anchors to the bottom.
It can clip the card edges in a tight 1-column stack, so add md:scale-[1.03] instead of scale-[1.03] to only apply the lift on wider screens. On mobile, use a top accent border to distinguish the recommended tier.