"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, Minus, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export type PricingTier = {
id: string;
name: string;
description: string;
/** Price per month when billed monthly. */
monthly: number;
/** Price per month when billed yearly. */
yearly: number;
features: string[];
cta: string;
popular?: boolean;
};
export type ComparisonRow = {
feature: string;
/** One value per tier, in tier order. `true` = check, `false` = dash, string = text. */
values: (boolean | string)[];
};
export type ComparisonGroup = { group: string; rows: ComparisonRow[] };
export interface PricingToggleProps {
eyebrow?: string;
title?: string;
subtitle?: string;
tiers?: PricingTier[];
comparison?: ComparisonGroup[];
currency?: string;
/** Label for the yearly savings badge. */
savingsLabel?: string;
defaultCycle?: "monthly" | "yearly";
onSelect?: (tier: PricingTier, cycle: "monthly" | "yearly") => void;
className?: string;
}
const DEFAULT_TIERS: PricingTier[] = [
{
id: "starter",
name: "Starter",
description: "Everything you need to launch your first project.",
monthly: 0,
yearly: 0,
cta: "Start for free",
features: ["Up to 3 projects", "1,000 monthly visitors", "Community support", "Basic analytics"],
},
{
id: "pro",
name: "Pro",
description: "For growing teams that ship every week.",
monthly: 29,
yearly: 23,
cta: "Start 14-day trial",
popular: true,
features: ["Unlimited projects", "100,000 monthly visitors", "Priority email support", "Advanced analytics", "Custom domains"],
},
{
id: "business",
name: "Business",
description: "Security, control and scale for larger orgs.",
monthly: 89,
yearly: 71,
cta: "Talk to sales",
features: ["Everything in Pro", "1M monthly visitors", "SSO & audit logs", "Dedicated success manager", "99.99% uptime SLA"],
},
];
const DEFAULT_COMPARISON: ComparisonGroup[] = [
{
group: "Usage",
rows: [
{ feature: "Projects", values: ["3", "Unlimited", "Unlimited"] },
{ feature: "Monthly visitors", values: ["1k", "100k", "1M"] },
{ feature: "Team seats", values: ["1", "10", "Unlimited"] },
{ feature: "File storage", values: ["1 GB", "100 GB", "2 TB"] },
],
},
{
group: "Features",
rows: [
{ feature: "Custom domains", values: [false, true, true] },
{ feature: "Advanced analytics", values: [false, true, true] },
{ feature: "A/B experiments", values: [false, true, true] },
{ feature: "Workflow automation", values: [false, "5 flows", "Unlimited"] },
],
},
{
group: "Security & support",
rows: [
{ feature: "SSO / SAML", values: [false, false, true] },
{ feature: "Audit logs", values: [false, false, true] },
{ feature: "Support", values: ["Community", "Priority email", "Dedicated manager"] },
],
},
];
/* ---------- rolling number ---------- */
function Digit({ value }: { value: number }) {
const reduce = useReducedMotion();
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
<motion.span
className="absolute inset-x-0 top-0 flex flex-col"
initial={false}
animate={{ y: `${-value * 10}%` }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 22, mass: 0.8 }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="flex h-[1em] items-center justify-center">
{n}
</span>
))}
</motion.span>
</span>
);
}
function RollingNumber({ value, className }: { value: number; className?: string }) {
const digits = String(Math.round(value)).split("");
return (
<span className={cn("inline-flex leading-none tabular-nums", className)} aria-hidden>
<AnimatePresence initial={false} mode="popLayout">
{digits.map((d, i) => {
const key = digits.length - i; // key from the right so units stay put when length changes
return (
<motion.span
key={key}
layout
initial={{ opacity: 0, y: "-0.4em" }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: "0.4em" }}
className="inline-block"
>
<Digit value={Number(d)} />
</motion.span>
);
})}
</AnimatePresence>
</span>
);
}
/* ---------- main ---------- */
function GradientBorder({ children }: { children: React.ReactNode }) {
const reduce = useReducedMotion();
return (
<div className="relative h-full rounded-3xl p-[1.5px]">
{/* soft glow */}
<div aria-hidden className="absolute -inset-2 rounded-[2rem] bg-gradient-to-br from-violet-500/30 via-fuchsia-500/20 to-sky-500/30 opacity-70 blur-2xl dark:opacity-50" />
<div aria-hidden className="absolute inset-0 overflow-hidden rounded-3xl">
<motion.div
className="absolute left-1/2 top-1/2 aspect-square w-[250%] -translate-x-1/2 -translate-y-1/2 bg-[conic-gradient(from_0deg,#8b5cf6,#d946ef,#f59e0b,#0ea5e9,#8b5cf6)]"
animate={reduce ? undefined : { rotate: 360 }}
transition={{ duration: 6, ease: "linear", repeat: Infinity }}
/>
</div>
<div className="relative h-full rounded-[calc(1.5rem-1.5px)] bg-card">{children}</div>
</div>
);
}
export function PricingToggle({
eyebrow = "Pricing",
title = "Simple pricing that scales with you",
subtitle = "Start free, upgrade when you're ready. Every plan includes unlimited API calls and our core toolkit.",
tiers = DEFAULT_TIERS,
comparison = DEFAULT_COMPARISON,
currency = "$",
savingsLabel = "Save 20%",
defaultCycle = "yearly",
onSelect,
className,
}: PricingToggleProps) {
const [cycle, setCycle] = React.useState<"monthly" | "yearly">(defaultCycle);
const [open, setOpen] = React.useState(false);
const tableId = React.useId();
const reduce = useReducedMotion();
return (
<section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
<div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(50%_40%_at_50%_0%,color-mix(in_oklch,var(--primary)_12%,transparent),transparent)]" />
<div className="relative mx-auto max-w-6xl">
<header className="mx-auto max-w-3xl text-center">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
<h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
<p className="mx-auto mt-4 max-w-2xl text-pretty text-base text-muted-foreground sm:text-lg">{subtitle}</p>
</header>
<div className="mt-10 flex items-center justify-center gap-3">
<div role="radiogroup" aria-label="Billing cycle" className="relative inline-flex rounded-full border bg-muted/60 p-1 text-sm">
{(["monthly", "yearly"] as const).map((c) => (
<button
key={c}
type="button"
role="radio"
aria-checked={cycle === c}
onClick={() => setCycle(c)}
onKeyDown={(e) => {
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) {
e.preventDefault();
const nextCycle = cycle === "monthly" ? "yearly" : "monthly";
setCycle(nextCycle);
const sibling = e.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`[data-cycle="${nextCycle}"]`);
sibling?.focus();
}
}}
tabIndex={cycle === c ? 0 : -1}
data-cycle={c}
className={cn(
"relative rounded-full px-5 py-2 font-medium capitalize transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cycle === c ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{cycle === c && (
<motion.span
layoutId="pricing-toggle-pill"
className="absolute inset-0 rounded-full bg-background shadow-sm ring-1 ring-border"
transition={{ type: "spring", stiffness: 420, damping: 34 }}
/>
)}
<span className="relative">{c}</span>
</button>
))}
</div>
<motion.span
animate={{ scale: cycle === "yearly" ? 1 : 0.92, opacity: cycle === "yearly" ? 1 : 0.55 }}
className="rounded-full bg-emerald-500/12 px-2.5 py-1 text-xs font-semibold text-emerald-700 ring-1 ring-emerald-500/25 dark:text-emerald-300"
>
{savingsLabel}
</motion.span>
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3 lg:items-stretch">
{tiers.map((tier, i) => {
const price = cycle === "yearly" ? tier.yearly : tier.monthly;
const body = (
<div className="flex h-full flex-col p-7 sm:p-8">
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-foreground">{tier.name}</h3>
{tier.popular && (
<span className="inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-violet-600 to-fuchsia-600 px-2.5 py-1 text-xs font-semibold text-white shadow-sm">
<Sparkles aria-hidden className="size-3" /> Most popular
</span>
)}
</div>
<p className="mt-2 min-h-10 text-sm text-muted-foreground">{tier.description}</p>
<div className="mt-6 flex items-end gap-1.5">
<span className="text-5xl font-semibold tracking-tight text-foreground">
<span className="mr-0.5 align-top text-2xl leading-none">{currency}</span>
<RollingNumber value={price} />
<span className="sr-only">
{price} per month{cycle === "yearly" ? ", billed yearly" : ""}
</span>
</span>
<span className="pb-1 text-sm text-muted-foreground">/ month</span>
</div>
<p className="mt-2 h-5 text-xs text-muted-foreground">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={cycle + price}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="inline-block"
>
{price === 0
? "Free forever, no card required"
: cycle === "yearly"
? `Billed ${currency}${price * 12} yearly`
: "Billed monthly, cancel anytime"}
</motion.span>
</AnimatePresence>
</p>
<button
type="button"
onClick={() => onSelect?.(tier, cycle)}
className={cn(
"mt-7 inline-flex h-11 items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
tier.popular
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25 hover:brightness-110"
: "border bg-background text-foreground hover:bg-accent",
)}
>
{tier.cta}
</button>
<ul className="mt-8 space-y-3 border-t pt-7 text-sm">
{tier.features.map((f) => (
<li key={f} className="flex items-start gap-3 text-foreground/85">
<span className={cn("mt-0.5 grid size-5 shrink-0 place-items-center rounded-full", tier.popular ? "bg-primary text-primary-foreground" : "bg-primary/12 text-primary")}>
<Check aria-hidden className="size-3" strokeWidth={3} />
</span>
{f}
</li>
))}
</ul>
</div>
);
return (
<motion.div
key={tier.id}
initial={reduce ? false : { opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: i * 0.08, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className={cn(tier.popular && "lg:-my-3")}
>
{tier.popular ? <GradientBorder>{body}</GradientBorder> : <div className="h-full rounded-3xl border bg-card">{body}</div>}
</motion.div>
);
})}
</div>
{comparison.length > 0 && (
<div className="mt-12">
<div className="flex justify-center">
<button
type="button"
aria-expanded={open}
aria-controls={tableId}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-2 rounded-full border bg-card px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{open ? "Hide full comparison" : "See all features"}
<motion.span animate={{ rotate: open ? 180 : 0 }} transition={{ type: "spring", stiffness: 300, damping: 22 }}>
<ChevronDown aria-hidden className="size-4" />
</motion.span>
</button>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={tableId}
key="table"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<div className="mt-8 overflow-x-auto rounded-3xl border bg-card">
<table className="w-full min-w-[560px] table-fixed text-left text-sm">
<caption className="sr-only">Feature comparison</caption>
<thead>
<tr className="border-b">
<th scope="col" className="w-[34%] px-6 py-4 font-medium text-muted-foreground">
Feature
</th>
{tiers.map((t) => (
<th key={t.id} scope="col" className={cn("px-4 py-4 text-center font-semibold", t.popular ? "text-primary" : "text-foreground")}>
{t.name}
</th>
))}
</tr>
</thead>
{comparison.map((g) => (
<tbody key={g.group}>
<tr>
<th colSpan={tiers.length + 1} scope="colgroup" className="bg-muted/50 px-6 py-2.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{g.group}
</th>
</tr>
{g.rows.map((r) => (
<tr key={r.feature} className="border-t border-border/60">
<th scope="row" className="px-6 py-3.5 font-normal text-foreground/85">
{r.feature}
</th>
{r.values.map((v, vi) => (
<td key={vi} className={cn("px-4 py-3.5 text-center", tiers[vi]?.popular && "bg-primary/[0.04]")}>
{v === true ? (
<Check aria-label="Included" className="mx-auto size-4 text-primary" strokeWidth={2.5} />
) : v === false ? (
<Minus aria-label="Not included" className="mx-auto size-4 text-muted-foreground/50" />
) : (
<span className="text-foreground/85">{v}</span>
)}
</td>
))}
</tr>
))}
</tbody>
))}
</table>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
</section>
);
}