Catalog
Code
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export type Plan = {
name: string;
description: string;
monthly: number;
yearly: number;
features: string[];
cta: string;
highlighted?: boolean;
};
export interface PricingPlansProps {
plans: Plan[];
currency?: string;
onSelect?: (plan: Plan, cycle: "monthly" | "yearly") => void;
className?: string;
}
export function PricingPlans({ plans, currency = "USD", onSelect, className }: PricingPlansProps) {
const [cycle, setCycle] = React.useState<"monthly" | "yearly">("yearly");
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 });
const maxSave = Math.max(...plans.map((p) => (p.monthly ? Math.round((1 - p.yearly / (p.monthly * 12)) * 100) : 0)));
return (
<section className={cn("w-full", className)}>
<div className="mb-10 flex justify-center">
<div role="radiogroup" aria-label="Billing cycle" className="relative inline-flex rounded-full border bg-muted/50 p-1 text-sm">
{(["monthly", "yearly"] as const).map((c) => (
<button
key={c}
role="radio"
aria-checked={cycle === c}
onClick={() => setCycle(c)}
className={cn("relative z-10 rounded-full px-4 py-1.5 font-medium capitalize transition", cycle === c ? "bg-background shadow-sm" : "text-muted-foreground")}
>
{c}
{c === "yearly" && maxSave > 0 && (
<span className="ml-1.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">-{maxSave}%</span>
)}
</button>
))}
</div>
</div>
<div className="mx-auto grid max-w-5xl gap-6 md:grid-cols-3">
{plans.map((p) => {
const price = cycle === "monthly" ? p.monthly : p.yearly / 12;
return (
<div
key={p.name}
className={cn(
"relative flex flex-col rounded-2xl border bg-card p-6 text-card-foreground",
p.highlighted && "border-primary shadow-xl shadow-primary/10 md:-translate-y-2",
)}
>
{p.highlighted && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground">Most popular</span>
)}
<h3 className="font-semibold">{p.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">{p.description}</p>
<p className="mt-6 flex items-baseline gap-1">
<span className="text-4xl font-semibold tracking-tight tabular-nums">{fmt.format(price)}</span>
<span className="text-sm text-muted-foreground">/mo</span>
</p>
<p className="h-5 text-xs text-muted-foreground">{cycle === "yearly" && p.yearly > 0 ? `${fmt.format(p.yearly)} billed yearly` : ""}</p>
<button
onClick={() => onSelect?.(p, cycle)}
className={cn(
"mt-6 h-10 rounded-lg text-sm font-medium transition",
p.highlighted ? "bg-primary text-primary-foreground hover:bg-primary/90" : "border hover:bg-accent",
)}
>
{p.cta}
</button>
<ul className="mt-6 space-y-2.5 text-sm">
{p.features.map((f) => (
<li key={f} className="flex gap-2">
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
<span>{f}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</section>
);
}