Pricing Table
Original · freefeature comparison pricing table
byWeb InnoventixReact + Tailwind
tablepricingtables
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/table-pricing.jsontable-pricing.tsx
"use client";
import { Fragment, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type BillingCycle = "monthly" | "annual";
type PlanId = "starter" | "growth" | "scale";
type FeatureValue = boolean | string;
interface Plan {
id: PlanId;
name: string;
tagline: string;
monthly: number;
annual: number;
cta: string;
popular?: boolean;
}
interface FeatureRow {
label: string;
hint?: string;
values: Record<PlanId, FeatureValue>;
}
interface FeatureCategory {
id: string;
name: string;
rows: FeatureRow[];
}
const PLANS: Plan[] = [
{
id: "starter",
name: "Starter",
tagline: "Side projects and kicking the tires.",
monthly: 0,
annual: 0,
cta: "Start free",
},
{
id: "growth",
name: "Growth",
tagline: "Teams shipping to production every week.",
monthly: 29,
annual: 23,
cta: "Start 14-day trial",
popular: true,
},
{
id: "scale",
name: "Scale",
tagline: "On-call rotations and compliance reviews.",
monthly: 89,
annual: 71,
cta: "Talk to sales",
},
];
const CATEGORIES: FeatureCategory[] = [
{
id: "monitoring",
name: "Monitoring",
rows: [
{ label: "Uptime monitors", values: { starter: "3", growth: "50", scale: "Unlimited" } },
{ label: "Fastest check interval", values: { starter: "5 min", growth: "30 sec", scale: "10 sec" } },
{ label: "Global check regions", values: { starter: "1", growth: "8", scale: "16" } },
{ label: "Multi-step API checks", values: { starter: false, growth: true, scale: true } },
{ label: "Cron & heartbeat checks", values: { starter: false, growth: true, scale: true } },
],
},
{
id: "alerting",
name: "Alerting",
rows: [
{ label: "Email & Slack alerts", values: { starter: true, growth: true, scale: true } },
{ label: "SMS & voice call", values: { starter: false, growth: true, scale: true } },
{ label: "On-call scheduling", values: { starter: false, growth: false, scale: true } },
{ label: "Escalation policies", values: { starter: false, growth: "Basic", scale: "Multi-tier" } },
],
},
{
id: "status",
name: "Status pages",
rows: [
{ label: "Public status page", values: { starter: false, growth: "1", scale: "Unlimited" } },
{ label: "Custom domain", values: { starter: false, growth: true, scale: true } },
{ label: "Subscriber notifications", values: { starter: false, growth: "500", scale: "Unlimited" } },
],
},
{
id: "security",
name: "Data & security",
rows: [
{ label: "Metrics retention", values: { starter: "7 days", growth: "90 days", scale: "13 months" } },
{ label: "SSO / SAML", values: { starter: false, growth: false, scale: true } },
{ label: "Audit log", values: { starter: false, growth: false, scale: true } },
{ label: "Uptime SLA", values: { starter: false, growth: "99.9%", scale: "99.99%" } },
],
},
{
id: "support",
name: "Support",
rows: [
{ label: "Docs & community", values: { starter: true, growth: true, scale: true } },
{ label: "Priority support", values: { starter: false, growth: true, scale: true } },
{ label: "Dedicated success manager", values: { starter: false, growth: false, scale: true } },
],
},
];
const CYCLES: { id: BillingCycle; label: string }[] = [
{ id: "monthly", label: "Monthly" },
{ id: "annual", label: "Annual" },
];
function IconCheck({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M4 10.5l3.6 3.6L16 5.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconDash({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M5 10h10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function IconChevron({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconSpark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className={className}>
<path d="M10 1l1.9 5L17 8l-5.1 2L10 15l-1.9-5L3 8l5.1-2L10 1z" />
</svg>
);
}
export default function PricingComparisonTable() {
const reduce = useReducedMotion();
const uid = useId().replace(/:/g, "");
const [billing, setBilling] = useState<BillingCycle>("annual");
const [highlight, setHighlight] = useState<PlanId | null>("growth");
const [open, setOpen] = useState<Record<string, boolean>>(() =>
Object.fromEntries(CATEGORIES.map((c) => [c.id, true])),
);
const cycleRefs = useRef<Record<BillingCycle, HTMLButtonElement | null>>({
monthly: null,
annual: null,
});
const allOpen = CATEGORIES.every((c) => open[c.id]);
const t = (d: number) =>
reduce ? { duration: 0 } : { duration: d, ease: [0.22, 1, 0.36, 1] as const };
function onCycleKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
const forward = e.key === "ArrowRight" || e.key === "ArrowDown";
const back = e.key === "ArrowLeft" || e.key === "ArrowUp";
if (!forward && !back) return;
e.preventDefault();
const idx = CYCLES.findIndex((c) => c.id === billing);
const next = CYCLES[(idx + (forward ? 1 : -1) + CYCLES.length) % CYCLES.length];
setBilling(next.id);
cycleRefs.current[next.id]?.focus();
}
function toggleAll() {
const target = !allOpen;
setOpen(Object.fromEntries(CATEGORIES.map((c) => [c.id, target])));
}
function colHighlight(planId: PlanId): string {
if (highlight !== planId) return "";
return "bg-indigo-50/70 dark:bg-indigo-500/10";
}
function renderValue(v: FeatureValue) {
if (v === true) {
return (
<>
<IconCheck className="mx-auto h-5 w-5 text-emerald-600 dark:text-emerald-400" />
<span className="sr-only">Included</span>
</>
);
}
if (v === false) {
return (
<>
<IconDash className="mx-auto h-5 w-5 text-slate-300 dark:text-zinc-600" />
<span className="sr-only">Not included</span>
</>
);
}
return <span className="text-sm font-semibold text-slate-800 dark:text-slate-100">{v}</span>;
}
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-50 px-4 py-20 sm:px-6 sm:py-28 lg:px-8 dark:from-zinc-950 dark:via-zinc-950 dark:to-black">
<style>{`
@keyframes mrtp-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -20px, 0); }
}
@keyframes mrtp-sweep {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.mrtp-blob { animation: mrtp-float 11s ease-in-out infinite; }
.mrtp-badge-sheen {
background-size: 200% 100%;
animation: mrtp-sweep 4.5s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.mrtp-blob, .mrtp-badge-sheen { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="mrtp-blob pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-600/20"
/>
<div
aria-hidden="true"
className="mrtp-blob pointer-events-none absolute bottom-0 right-[8%] h-64 w-64 rounded-full bg-violet-400/15 blur-3xl dark:bg-violet-600/15"
style={{ animationDelay: "-3.5s" }}
/>
<div className="relative mx-auto max-w-6xl">
<motion.div
initial={reduce ? false : { opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={t(0.6)}
className="mx-auto max-w-2xl text-center"
>
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-600 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-indigo-300">
<IconSpark className="h-3.5 w-3.5" />
Meridian pricing
</span>
<h2 className="mt-5 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Monitoring that scales from your first check to your busiest launch
</h2>
<p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
Every plan includes unlimited teammates, the full REST & Terraform API, and zero
per-seat fees. You upgrade only when your infrastructure does.
</p>
</motion.div>
<motion.div
initial={reduce ? false : { opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={t(0.6)}
className="mt-9 flex flex-wrap items-center justify-center gap-4"
>
<div
role="radiogroup"
aria-label="Billing cycle"
className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
{CYCLES.map((c) => {
const active = billing === c.id;
return (
<button
key={c.id}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
ref={(el) => {
cycleRefs.current[c.id] = el;
}}
onClick={() => setBilling(c.id)}
onKeyDown={onCycleKeyDown}
className={`relative rounded-full px-5 py-1.5 text-sm font-semibold transition-colors ${focusRing} ${
active
? "bg-slate-900 text-white dark:bg-white dark:text-zinc-900"
: "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white"
}`}
>
{c.label}
</button>
);
})}
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
<IconCheck className="h-3.5 w-3.5" />
Save 20% annually
</span>
</motion.div>
<motion.div
initial={reduce ? false : { opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={t(0.7)}
className="mt-10 overflow-hidden rounded-3xl border border-slate-200 bg-white/80 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/60 dark:shadow-black/40"
>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4 dark:border-zinc-800">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-200">
Compare every feature
</p>
<button
type="button"
onClick={toggleAll}
aria-pressed={allOpen}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-zinc-800 dark:hover:text-white ${focusRing}`}
>
<IconChevron
className={`h-4 w-4 transition-transform duration-300 ${allOpen ? "rotate-180" : ""}`}
/>
{allOpen ? "Collapse all" : "Expand all"}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] border-collapse text-left">
<caption className="sr-only">
Meridian plan feature comparison across Starter, Growth, and Scale tiers
</caption>
<thead>
<tr className="align-bottom">
<th
scope="col"
className="sticky left-0 z-20 w-[34%] min-w-[220px] bg-white/95 px-5 pb-6 pt-6 align-bottom backdrop-blur dark:bg-zinc-900/95"
>
<span className="text-sm font-semibold text-slate-500 dark:text-slate-400">
Choose a plan to highlight
</span>
</th>
{PLANS.map((p) => {
const active = highlight === p.id;
const price = billing === "annual" ? p.annual : p.monthly;
const emphasize = active || p.popular;
return (
<th
key={p.id}
scope="col"
className={`relative w-[22%] min-w-[190px] px-4 pb-6 pt-6 align-top ${colHighlight(
p.id,
)} ${p.popular ? "border-x border-indigo-200 dark:border-indigo-500/30" : ""}`}
>
{p.popular && (
<span className="mrtp-badge-sheen absolute -top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500 px-3 py-1 text-[11px] font-bold uppercase tracking-wide text-white shadow-sm">
Most popular
</span>
)}
<button
type="button"
aria-pressed={active}
aria-label={`Highlight the ${p.name} plan column`}
onClick={() => setHighlight((prev) => (prev === p.id ? null : p.id))}
className={`relative block w-full rounded-2xl border p-4 text-left transition-colors ${focusRing} ${
active
? "border-indigo-500 bg-white shadow-sm dark:border-indigo-400 dark:bg-zinc-900"
: "border-transparent hover:border-slate-200 hover:bg-slate-50 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/50"
}`}
>
<span className="flex items-center justify-between">
<span className="text-base font-bold text-slate-900 dark:text-white">
{p.name}
</span>
<span
className={`flex h-5 w-5 items-center justify-center rounded-full border transition-colors ${
active
? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-400 dark:text-zinc-900"
: "border-slate-300 text-transparent dark:border-zinc-600"
}`}
>
<IconCheck className="h-3.5 w-3.5" />
</span>
</span>
<span className="relative mt-4 block h-16">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={billing}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={t(0.22)}
className="absolute inset-0 block"
>
{p.monthly === 0 ? (
<>
<span className="text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white">
$0
</span>
<span className="mt-1 block text-xs font-medium text-slate-500 dark:text-slate-400">
Free forever
</span>
</>
) : (
<>
<span className="flex items-baseline gap-1.5">
<span className="text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white">
${price}
</span>
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
/mo
</span>
{billing === "annual" && (
<span className="text-xs font-medium text-slate-400 line-through dark:text-zinc-500">
${p.monthly}
</span>
)}
</span>
<span className="mt-1 block text-xs font-medium text-slate-500 dark:text-slate-400">
{billing === "annual" ? "billed annually" : "billed monthly"}
</span>
</>
)}
</motion.span>
</AnimatePresence>
</span>
<span className="mt-4 block text-sm leading-snug text-slate-500 dark:text-slate-400">
{p.tagline}
</span>
</button>
<button
type="button"
className={`mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors ${focusRing} ${
emphasize
? "bg-indigo-600 text-white shadow-sm hover:bg-indigo-500"
: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700"
}`}
>
{p.cta}
</button>
</th>
);
})}
</tr>
</thead>
{CATEGORIES.map((cat) => {
const rowsId = `${uid}-${cat.id}-rows`;
const isOpen = open[cat.id];
return (
<Fragment key={cat.id}>
<tbody className="border-t border-slate-200 dark:border-zinc-800">
<tr>
<th scope="colgroup" colSpan={PLANS.length + 1} className="p-0">
<button
type="button"
aria-expanded={isOpen}
aria-controls={rowsId}
onClick={() =>
setOpen((prev) => ({ ...prev, [cat.id]: !prev[cat.id] }))
}
className={`flex w-full items-center gap-2 bg-slate-50/80 px-5 py-3 text-left text-sm font-bold uppercase tracking-wide text-slate-700 transition-colors hover:bg-slate-100 dark:bg-zinc-900/70 dark:text-slate-200 dark:hover:bg-zinc-800/70 ${focusRing}`}
>
<IconChevron
className={`h-4 w-4 text-slate-400 transition-transform duration-300 dark:text-slate-500 ${
isOpen ? "" : "-rotate-90"
}`}
/>
{cat.name}
<span className="text-xs font-medium normal-case text-slate-400 dark:text-slate-500">
{cat.rows.length} features
</span>
</button>
</th>
</tr>
</tbody>
<tbody id={rowsId} hidden={!isOpen}>
{cat.rows.map((row) => (
<tr
key={row.label}
className="group border-t border-slate-100 dark:border-zinc-800/70"
>
<th
scope="row"
className="sticky left-0 z-10 w-[34%] min-w-[220px] bg-white/95 px-5 py-3.5 text-left text-sm font-medium text-slate-700 backdrop-blur transition-colors group-hover:bg-slate-50 dark:bg-zinc-900/95 dark:text-slate-300 dark:group-hover:bg-zinc-800/50"
>
{row.label}
</th>
{PLANS.map((p) => (
<td
key={p.id}
className={`w-[22%] min-w-[190px] px-4 py-3.5 text-center transition-colors group-hover:bg-slate-50/60 dark:group-hover:bg-zinc-800/30 ${colHighlight(
p.id,
)} ${
p.popular
? "border-x border-indigo-100 dark:border-indigo-500/20"
: ""
}`}
>
{renderValue(row.values[p.id])}
</td>
))}
</tr>
))}
</tbody>
</Fragment>
);
})}
</table>
</div>
<div className="border-t border-slate-200 px-5 py-4 text-center text-xs text-slate-500 dark:border-zinc-800 dark:text-slate-400">
All prices in USD. Cancel anytime — paid plans include a 14-day money-back guarantee.
</div>
</motion.div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row table

Bordered Table
Originalbordered table

Hover Table
Originalrow-hover highlight table

Sortable Table
Originalsortable-column table

Selectable Table
Originaltable with row checkboxes and select-all

Sticky Header Table
Originalsticky header scroll table

Pagination Table
Originaltable with footer pagination

Search Table
Originaltable with a search filter

Expandable Table
Originaltable with expandable rows

Actions Table
Originaltable with row action menus

Status Badges Table
Originaltable with status badges

