Pricing Table
Three-tier pricing with an animated monthly/yearly toggle and rolling prices.
Simple, transparent pricing
Start free. Upgrade when your team is ready.
Starter
For individuals and side projects.
$0/ month
- 3 projects
- Basic analytics
- Community support
- 1 GB storage
Most popular
Pro
For growing teams that ship weekly.
$24/ month
- Unlimited projects
- Advanced analytics
- Priority support
- 100 GB storage
- Custom domains
Enterprise
For organizations at scale.
$79/ month
- Everything in Pro
- SSO & SCIM
- Audit logs
- Dedicated manager
- 99.99% SLA
components/ui/pricing-table.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Check } from "lucide-react";
import { useState } from "react";
type Plan = {
name: string;
description: string;
monthly: number;
yearly: number;
features: string[];
cta: string;
featured?: boolean;
};
const plans: Plan[] = [
{ name: "Starter", description: "For individuals and side projects.", monthly: 0, yearly: 0, cta: "Start for free", features: ["3 projects", "Basic analytics", "Community support", "1 GB storage"] },
{ name: "Pro", description: "For growing teams that ship weekly.", monthly: 29, yearly: 24, cta: "Start 14-day trial", featured: true, features: ["Unlimited projects", "Advanced analytics", "Priority support", "100 GB storage", "Custom domains"] },
{ name: "Enterprise", description: "For organizations at scale.", monthly: 99, yearly: 79, cta: "Contact sales", features: ["Everything in Pro", "SSO & SCIM", "Audit logs", "Dedicated manager", "99.99% SLA"] },
];
export function BillingToggle({ yearly, onChange }: { yearly: boolean; onChange: (yearly: boolean) => void }) {
return (
<div className="relative inline-flex rounded-full border border-zinc-200 bg-zinc-50 p-1 text-sm font-medium">
{[false, true].map((isYearly) => (
<button
key={String(isYearly)}
onClick={() => onChange(isYearly)}
className={`relative z-10 rounded-full px-5 py-2 transition-colors ${yearly === isYearly ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-700"}`}
>
{yearly === isYearly && (
<motion.span
layoutId="billing-pill"
className="absolute inset-0 -z-10 rounded-full bg-white shadow-sm ring-1 ring-zinc-200"
transition={{ type: "spring", stiffness: 400, damping: 32 }}
/>
)}
{isYearly ? (
<>
Yearly <span className="ml-1 text-xs text-emerald-600">-20%</span>
</>
) : (
"Monthly"
)}
</button>
))}
</div>
);
}
export function PricingTable({ items = plans }: { items?: Plan[] }) {
const [yearly, setYearly] = useState(true);
return (
<div className="w-full">
<div className="flex justify-center">
<BillingToggle yearly={yearly} onChange={setYearly} />
</div>
<div className="mt-10 grid gap-5 lg:grid-cols-3">
{items.map((plan, i) => {
const price = yearly ? plan.yearly : plan.monthly;
return (
<motion.div
key={plan.name}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.6, delay: i * 0.1, ease: [0.22, 1, 0.36, 1] }}
className={`relative flex flex-col rounded-3xl p-7 ${
plan.featured
? "bg-zinc-900 text-white shadow-2xl shadow-zinc-900/25 lg:-my-3 lg:py-10"
: "border border-zinc-200 bg-white text-zinc-900"
}`}
>
{plan.featured && (
<span className="absolute right-6 top-6 rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-white ring-1 ring-white/20">
Most popular
</span>
)}
<h3 className="text-lg font-semibold">{plan.name}</h3>
<p className={`mt-1 text-sm ${plan.featured ? "text-zinc-400" : "text-zinc-500"}`}>{plan.description}</p>
<div className="mt-6 flex items-baseline gap-1">
<span className="text-5xl font-semibold tracking-tight">$</span>
<span className="relative inline-flex h-[1.1em] overflow-hidden text-5xl font-semibold tracking-tight">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={price}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 28 }}
>
{price}
</motion.span>
</AnimatePresence>
</span>
<span className={`ml-1 text-sm ${plan.featured ? "text-zinc-400" : "text-zinc-500"}`}>/ month</span>
</div>
<ul className="mt-7 flex-1 space-y-3 text-sm">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2.5">
<Check className={`size-4 shrink-0 ${plan.featured ? "text-blue-400" : "text-blue-600"}`} />
<span className={plan.featured ? "text-zinc-300" : "text-zinc-600"}>{f}</span>
</li>
))}
</ul>
<button
className={`mt-8 rounded-full py-3 text-sm font-medium transition-all hover:-translate-y-0.5 ${
plan.featured ? "bg-white text-zinc-900 hover:bg-zinc-100" : "bg-zinc-900 text-white hover:bg-zinc-800"
}`}
>
{plan.cta}
</button>
</motion.div>
);
})}
</div>
</div>
);
}
export default function Demo() {
return (
<section className="w-full max-w-5xl py-6">
<div className="mb-10 text-center">
<h2 className="text-4xl font-semibold tracking-tight text-zinc-900">Simple, transparent pricing</h2>
<p className="mt-3 text-zinc-500">Start free. Upgrade when your team is ready.</p>
</div>
<PricingTable />
</section>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/pricing-table.tsxcomponents/ui/pricing-table.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Check } from "lucide-react"; import { useState } from "react"; type Plan = { name: string; description: string; monthly: number; yearly: number; features: string[]; cta: string; featured?: boolean; }; const plans: Plan[] = [ { name: "Starter", description: "For individuals and side projects.", monthly: 0, yearly: 0, cta: "Start for free", features: ["3 projects", "Basic analytics", "Community support", "1 GB storage"] }, { name: "Pro", description: "For growing teams that ship weekly.", monthly: 29, yearly: 24, cta: "Start 14-day trial", featured: true, features: ["Unlimited projects", "Advanced analytics", "Priority support", "100 GB storage", "Custom domains"] }, { name: "Enterprise", description: "For organizations at scale.", monthly: 99, yearly: 79, cta: "Contact sales", features: ["Everything in Pro", "SSO & SCIM", "Audit logs", "Dedicated manager", "99.99% SLA"] }, ]; export function BillingToggle({ yearly, onChange }: { yearly: boolean; onChange: (yearly: boolean) => void }) { return ( <div className="relative inline-flex rounded-full border border-zinc-200 bg-zinc-50 p-1 text-sm font-medium"> {[false, true].map((isYearly) => ( <button key={String(isYearly)} onClick={() => onChange(isYearly)} className={`relative z-10 rounded-full px-5 py-2 transition-colors ${yearly === isYearly ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-700"}`} > {yearly === isYearly && ( <motion.span layoutId="billing-pill" className="absolute inset-0 -z-10 rounded-full bg-white shadow-sm ring-1 ring-zinc-200" transition={{ type: "spring", stiffness: 400, damping: 32 }} /> )} {isYearly ? ( <> Yearly <span className="ml-1 text-xs text-emerald-600">-20%</span> </> ) : ( "Monthly" )} </button> ))} </div> ); } export function PricingTable({ items = plans }: { items?: Plan[] }) { const [yearly, setYearly] = useState(true); return ( <div className="w-full"> <div className="flex justify-center"> <BillingToggle yearly={yearly} onChange={setYearly} /> </div> <div className="mt-10 grid gap-5 lg:grid-cols-3"> {items.map((plan, i) => { const price = yearly ? plan.yearly : plan.monthly; return ( <motion.div key={plan.name} initial={{ opacity: 0, y: 24 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, amount: 0.3 }} transition={{ duration: 0.6, delay: i * 0.1, ease: [0.22, 1, 0.36, 1] }} className={`relative flex flex-col rounded-3xl p-7 ${ plan.featured ? "bg-zinc-900 text-white shadow-2xl shadow-zinc-900/25 lg:-my-3 lg:py-10" : "border border-zinc-200 bg-white text-zinc-900" }`} > {plan.featured && ( <span className="absolute right-6 top-6 rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-white ring-1 ring-white/20"> Most popular </span> )} <h3 className="text-lg font-semibold">{plan.name}</h3> <p className={`mt-1 text-sm ${plan.featured ? "text-zinc-400" : "text-zinc-500"}`}>{plan.description}</p> <div className="mt-6 flex items-baseline gap-1"> <span className="text-5xl font-semibold tracking-tight">$</span> <span className="relative inline-flex h-[1.1em] overflow-hidden text-5xl font-semibold tracking-tight"> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={price} initial={{ y: "100%", opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: "-100%", opacity: 0 }} transition={{ type: "spring", stiffness: 300, damping: 28 }} > {price} </motion.span> </AnimatePresence> </span> <span className={`ml-1 text-sm ${plan.featured ? "text-zinc-400" : "text-zinc-500"}`}>/ month</span> </div> <ul className="mt-7 flex-1 space-y-3 text-sm"> {plan.features.map((f) => ( <li key={f} className="flex items-center gap-2.5"> <Check className={`size-4 shrink-0 ${plan.featured ? "text-blue-400" : "text-blue-600"}`} /> <span className={plan.featured ? "text-zinc-300" : "text-zinc-600"}>{f}</span> </li> ))} </ul> <button className={`mt-8 rounded-full py-3 text-sm font-medium transition-all hover:-translate-y-0.5 ${ plan.featured ? "bg-white text-zinc-900 hover:bg-zinc-100" : "bg-zinc-900 text-white hover:bg-zinc-800" }`} > {plan.cta} </button> </motion.div> ); })} </div> </div> ); } export default function Demo() { return ( <section className="w-full max-w-5xl py-6"> <div className="mb-10 text-center"> <h2 className="text-4xl font-semibold tracking-tight text-zinc-900">Simple, transparent pricing</h2> <p className="mt-3 text-zinc-500">Start free. Upgrade when your team is ready.</p> </div> <PricingTable /> </section> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.