Pricing Table
A three-column pricing section with an animated monthly/yearly toggle and per-feature comparison.
Live preview and source code for Pricing Table
"use client"
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check, Minus } from "lucide-react";
import Badge from "../../components/badge/component";
import Button from "../../components/button/component";
export interface PricingTier {
name: string;
/** Monthly price in whole currency units. Use 0 for a free tier */
monthly: number;
/** Yearly price *per month* when billed annually */
yearly: number;
description: string;
/** `true` renders a check, `false` a dash */
features: Array<{ label: string; included: boolean }>;
cta: string;
/** Lifts the card and adds the "Most popular" ribbon */
featured?: boolean;
}
export interface PricingTableProps {
tiers?: PricingTier[];
/** Currency symbol shown before the amount */
currency?: string;
/** Which cycle the toggle starts on */
defaultCycle?: "monthly" | "yearly";
/** Discount copy shown next to the yearly option */
yearlyNote?: string;
}
const DEFAULT_TIERS: PricingTier[] = [
{
name: "Starter",
monthly: 0,
yearly: 0,
description: "Everything you need to publish your first project.",
cta: "Get started",
features: [
{ label: "3 projects", included: true },
{ label: "Community support", included: true },
{ label: "Basic analytics", included: true },
{ label: "Custom domain", included: false },
{ label: "Priority review", included: false },
],
},
{
name: "Pro",
monthly: 24,
yearly: 19,
description: "For working designers and small product teams.",
cta: "Start free trial",
featured: true,
features: [
{ label: "Unlimited projects", included: true },
{ label: "Email + chat support", included: true },
{ label: "Advanced analytics", included: true },
{ label: "Custom domain", included: true },
{ label: "Priority review", included: false },
],
},
{
name: "Studio",
monthly: 79,
yearly: 64,
description: "Scale across clients with shared workspaces and SSO.",
cta: "Talk to sales",
features: [
{ label: "Everything in Pro", included: true },
{ label: "Dedicated manager", included: true },
{ label: "Usage reporting", included: true },
{ label: "SSO & audit log", included: true },
{ label: "Priority review", included: true },
],
},
];
/**
* PricingTable — three tiers with an animated monthly/yearly toggle,
* a featured middle column, and per-feature check/dash rows.
* Renders standalone with zero props.
*/
export default function PricingTable({
tiers = DEFAULT_TIERS,
currency = "$",
defaultCycle = "monthly",
yearlyNote = "Save 20%",
}: PricingTableProps) {
const [cycle, setCycle] = React.useState<"monthly" | "yearly">(defaultCycle);
const isYearly = cycle === "yearly";
return (
<section className="pt-root">
<header className="pt-head">
<h2 className="pt-title">Simple, honest pricing</h2>
<p className="pt-sub">Start free. Upgrade when your work outgrows it — cancel any time.</p>
<div className="pt-toggle" role="group" aria-label="Billing cycle">
{(["monthly", "yearly"] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setCycle(option)}
aria-pressed={cycle === option}
className={`pt-toggle-btn ${cycle === option ? "is-active" : ""}`}
>
{cycle === option && (
<motion.span
layoutId="pt-toggle-pill"
className="pt-toggle-pill"
transition={{ type: "spring", stiffness: 420, damping: 34 }}
/>
)}
<span className="pt-toggle-label">{option === "monthly" ? "Monthly" : "Yearly"}</span>
</button>
))}
</div>
<AnimatePresence>
{isYearly && yearlyNote && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
>
<Badge label={yearlyNote} variant="success" size="sm" dot />
</motion.div>
)}
</AnimatePresence>
</header>
<div className="pt-grid">
{tiers.map((tier) => {
const price = isYearly ? tier.yearly : tier.monthly;
return (
<div key={tier.name} className={`pt-card ${tier.featured ? "is-featured" : ""}`}>
{tier.featured && (
<div className="pt-ribbon">
<Badge label="Most popular" variant="info" size="sm" solid />
</div>
)}
<div className="pt-card-head">
<h3 className="pt-tier">{tier.name}</h3>
<p className="pt-tier-desc">{tier.description}</p>
</div>
<div className="pt-price-row">
<span className="pt-currency">{currency}</span>
{/* Keyed so the number itself animates when the cycle flips */}
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={price}
className="pt-price"
initial={{ opacity: 0, y: 10, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -10, filter: "blur(4px)", position: "absolute" }}
transition={{ duration: 0.22, ease: "easeOut" }}
>
{price}
</motion.span>
</AnimatePresence>
<span className="pt-per">{price === 0 ? "forever" : "/mo"}</span>
</div>
<div className="pt-billed">
{price === 0 ? "No card required" : isYearly ? "billed annually" : "billed monthly"}
</div>
<Button
label={tier.cta}
icon={null}
size="md"
fullWidth
blobColor={tier.featured ? "#c4b5fd" : "#e4e4e7"}
/>
<ul className="pt-features">
{tier.features.map((feature) => (
<li key={feature.label} className={feature.included ? "" : "is-muted"}>
<span className={`pt-icon ${feature.included ? "is-yes" : "is-no"}`}>
{feature.included ? <Check size={12} strokeWidth={3} /> : <Minus size={12} strokeWidth={3} />}
</span>
{feature.label}
</li>
))}
</ul>
</div>
);
})}
</div>
<style>{`
.pt-root {
width: 100%;
padding: 72px 24px;
background: #fbfbfd;
font-family: inherit;
color: #18181b;
}
.pt-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
text-align: center;
margin-bottom: 44px;
}
.pt-title {
margin: 0;
font-size: clamp(28px, 4vw, 40px);
letter-spacing: -0.03em;
font-weight: 600;
}
.pt-sub {
margin: 0;
max-width: 460px;
font-size: 15px;
line-height: 1.6;
color: #52525b;
text-wrap: balance;
}
.pt-toggle {
position: relative;
display: inline-flex;
padding: 4px;
background: #f4f4f5;
border: 1px solid rgba(24,24,27,0.07);
border-radius: 9999px;
}
.pt-toggle-btn {
position: relative;
padding: 7px 20px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: #71717a;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
transition: color 0.2s ease;
}
.pt-toggle-btn.is-active { color: #18181b; }
.pt-toggle-pill {
position: absolute;
inset: 0;
background: #ffffff;
border-radius: 9999px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.pt-toggle-label { position: relative; z-index: 1; }
.pt-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
max-width: 1040px;
margin: 0 auto;
align-items: start;
}
.pt-card {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
padding: 28px 24px;
background: #ffffff;
border: 1px solid rgba(24,24,27,0.09);
border-radius: 18px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.pt-card.is-featured {
border-color: rgba(124,58,237,0.35);
box-shadow: 0 18px 40px -22px rgba(124,58,237,0.55), 0 1px 2px rgba(0,0,0,0.04);
}
.pt-ribbon {
position: absolute;
top: -11px;
left: 24px;
}
.pt-card-head { display: flex; flex-direction: column; gap: 6px; }
.pt-tier { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
/* Reserve two lines so a one-line tier description doesn't pull its
price row out of alignment with the neighbouring cards. */
.pt-tier-desc {
margin: 0;
min-height: calc(2 * 1.55em);
font-size: 13.5px;
line-height: 1.55;
color: #71717a;
}
.pt-price-row {
position: relative;
display: flex;
align-items: baseline;
gap: 3px;
}
.pt-currency { font-size: 20px; font-weight: 600; color: #3f3f46; }
.pt-price {
display: inline-block;
font-size: 42px;
font-weight: 600;
letter-spacing: -0.04em;
line-height: 1;
}
.pt-per { font-size: 14px; color: #71717a; margin-left: 3px; }
.pt-billed {
margin-top: -12px;
font-size: 12.5px;
color: #a1a1aa;
}
.pt-features {
list-style: none;
margin: 4px 0 0;
padding: 18px 0 0;
border-top: 1px solid rgba(24,24,27,0.07);
display: flex;
flex-direction: column;
gap: 11px;
font-size: 13.5px;
}
.pt-features li {
display: flex;
align-items: center;
gap: 9px;
color: #3f3f46;
}
.pt-features li.is-muted { color: #a1a1aa; }
.pt-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 9999px;
flex-shrink: 0;
}
.pt-icon.is-yes { background: #dcfce7; color: #15803d; }
.pt-icon.is-no { background: #f4f4f5; color: #a1a1aa; }
@media (max-width: 900px) {
.pt-grid { grid-template-columns: 1fr; max-width: 420px; }
.pt-root { padding: 56px 18px; }
}
`}</style>
</section>
);
}
Simple, honest pricing
Start free. Upgrade when your work outgrows it — cancel any time.
Starter
Everything you need to publish your first project.
- 3 projects
- Community support
- Basic analytics
- Custom domain
- Priority review
Pro
For working designers and small product teams.
- Unlimited projects
- Email + chat support
- Advanced analytics
- Custom domain
- Priority review
Studio
Scale across clients with shared workspaces and SSO.
- Everything in Pro
- Dedicated manager
- Usage reporting
- SSO & audit log
- Priority review
A three-column pricing section with a monthly and yearly billing toggle, a featured middle tier and a check or dash against every feature. The toggle slides a pill between the two options and the price itself animates when the cycle flips, so the change is visible without the layout moving. Tiers, the currency symbol and the starting cycle are props, and the defaults are a complete example you can read before replacing.
Installation
Copy the source from the Code tab above into your project as PricingTable.tsx.
Its styles are a <style> block of pt- prefixed classes that travels with the
file, so there is no stylesheet to import.
It imports framer-motion for the toggle pill and the price transition, and
lucide-react for the check and dash icons:
npm install framer-motion lucide-reactIt also renders two items from this collection, so copy those files too and fix the import paths at the top to match where you put them: Badge and Button.
Usage
Rendered with no props it shows three example tiers. Pass your own to replace them:
import PricingTable from "./components/PricingTable";
const tiers = [
{
name: "Starter",
monthly: 0,
yearly: 0,
description: "Everything you need to publish your first project.",
cta: "Get started",
features: [
{ label: "3 projects", included: true },
{ label: "Custom domain", included: false },
],
},
];
export default function Example() {
return <PricingTable tiers={tiers} currency="$" defaultCycle="monthly" />;
}monthly and yearly are both per-month numbers, where yearly is the
discounted rate when billed annually. featured on a tier lifts the card and
adds the "Most popular" ribbon. yearlyNote is the discount label beside the
toggle, and an empty string removes it.
When to reach for it
A SaaS pricing page with a small number of plans and an annual discount, which is what the toggle and the per-month yearly figure are shaped for.
A comparison section where each plan's features matter more than a full matrix: five or six rows per tier, marked included or not.
An upgrade screen inside a product, using defaultCycle="yearly" so the
discounted price is what a returning user sees first.
When not to
If you have more or fewer than three tiers. The grid is a fixed three columns above 900px. Two tiers leave an empty column and four wrap into a row of three plus one on its own.
If pricing is usage based or quoted. There is one number per tier per cycle. Sliders, seat counts, metered rates and "contact us" ranges all need a different layout.
If prices need real formatting. Amounts are printed as raw numbers behind a symbol, so there are no thousands separators, no decimals and no locale awareness. Anything beyond a whole dollar or euro means formatting the value before it reaches the tier.
Accessibility
The toggle is a pair of real buttons in a role="group" labelled "Billing
cycle", and each carries aria-pressed, so the current cycle is announced
rather than only shown by the sliding pill.
Included and excluded features are marked with a check and a dash rather than with colour alone, but neither icon has a text alternative, so a screen reader reads the feature label with nothing to say whether it is included. If that matters, add visually hidden text beside each icon in the copied file.
None of the animation is gated on prefers-reduced-motion: the pill slides,
the price blurs and moves, and the discount badge animates in and out.
Notes
The section heading and its subtitle are hard-coded in the source rather than being props. They are the first thing to change in the copied file.
Feature lists belong to each tier independently, so nothing aligns rows across columns. Keeping the labels in the same order in every tier is what makes the table read as a comparison, and the component will not do it for you.
A tier priced at 0 prints "forever" instead of "/mo" and "No card required"
instead of the billing line, in both cycles.
The call to action on each card is a Button with no click handler. Wire each one to your checkout before shipping.