"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export type BillingPeriod = "monthly" | "yearly";
export interface PricingPlan {
id: string;
name: string;
/** One line under the plan name. */
description?: string;
/** Price per month when billed monthly. */
monthly: number;
/** Price per year when billed yearly. */
yearly: number;
features: string[];
/** Call-to-action label. */
cta: string;
/** Turns the call to action into a link. */
href?: string;
/** Emphasise this plan (accent border, filled button, badge). */
highlighted?: boolean;
/** Badge text for a highlighted plan. Defaults to "Popular". */
badge?: string;
}
export interface PricingToggleProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children"> {
plans: PricingPlan[];
/** Selected billing period (controlled). */
billing?: BillingPeriod;
/** Initial billing period (uncontrolled). */
defaultBilling?: BillingPeriod;
onBillingChange?: (billing: BillingPeriod) => void;
/** ISO 4217 currency code. */
currency?: string;
/** BCP 47 locale used by Intl.NumberFormat. */
locale?: string;
/** With yearly billing, show the monthly equivalent or the yearly total as the big number. */
yearlyDisplay?: "perMonth" | "perYear";
/** How long each digit takes to roll, in ms. */
rollDuration?: number;
/** Show “Save X%” badges when yearly billing is selected. */
showSavings?: boolean;
/** Called when a plan's call to action is pressed (buttons only, not links). */
onSelectPlan?: (plan: PricingPlan, billing: BillingPeriod) => void;
/** Labels for the billing switch. */
monthlyLabel?: string;
yearlyLabel?: string;
/** Highlight, toggle thumb and focus colour. */
accent?: string;
}
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
const ROW = 1.15; // em — line box of one digit
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** One digit wheel. A CSS transition rolls it from whatever it showed before — never from zero. */
function DigitWheel({ digit, duration }: { digit: number; duration: number }) {
return (
<span className="relative inline-block overflow-hidden" style={{ height: `${ROW}em`, lineHeight: ROW }}>
<span className="invisible">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col items-center transition-transform ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${-digit * ROW}em)`, transitionDuration: `${duration}ms` }}
>
{DIGITS.map((d) => (
<span key={d} style={{ height: `${ROW}em` }}>
{d}
</span>
))}
</span>
</span>
);
}
/**
* Splits a formatted number into keyed tokens. Integer digits are keyed by their position from the
* right, so “29” → “290” keeps the 9 and 2 wheels (they roll) and only adds a new one.
*/
function tokens(formatter: Intl.NumberFormat, value: number) {
const parts = formatter.formatToParts(value);
const integerDigits = parts.filter((p) => p.type === "integer").reduce((n, p) => n + p.value.length, 0);
let seenInteger = 0;
let fraction = 0;
const counts: Record<string, number> = {};
const out: { key: string; char: string; digit: boolean }[] = [];
for (const part of parts) {
if (part.type === "integer" || part.type === "fraction") {
for (const char of part.value) {
const isDigit = char >= "0" && char <= "9";
const key = part.type === "integer" ? `i${integerDigits - seenInteger++}` : `f${fraction++}`;
out.push({ key, char, digit: isDigit });
}
} else {
// Separators are keyed by the digit position that follows them; other literals by order of appearance.
const n = (counts[part.type] = (counts[part.type] ?? 0) + 1);
const key = part.type === "group" ? `g${integerDigits - seenInteger}` : `${part.type}${n}`;
out.push({ key, char: part.value, digit: false });
}
}
return out;
}
function RollingPrice({ value, formatter, duration }: { value: number; formatter: Intl.NumberFormat; duration: number }) {
return (
<span aria-hidden="true" className="inline-flex items-baseline whitespace-pre tabular-nums">
{tokens(formatter, value).map((t) =>
t.digit ? <DigitWheel key={t.key} digit={Number(t.char)} duration={duration} /> : <span key={t.key}>{t.char}</span>,
)}
</span>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="mt-[3px] size-3.5 shrink-0">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
);
}
/**
* Pricing cards with a monthly / yearly switch. Prices roll digit by digit from their previous value,
* per-plan savings badges spring in for yearly billing, and a polite live region announces the new prices.
*/
export function PricingToggle({
plans,
billing: billingProp,
defaultBilling = "monthly",
onBillingChange,
currency = "USD",
locale = "en-US",
yearlyDisplay = "perMonth",
rollDuration = 600,
showSavings = true,
onSelectPlan,
monthlyLabel = "Monthly",
yearlyLabel = "Yearly",
accent = "#FF7448",
className = "",
style,
...props
}: PricingToggleProps) {
const id = React.useId();
const reduceMotion = usePrefersReducedMotion();
const [internal, setInternal] = React.useState<BillingPeriod>(defaultBilling);
const billing = billingProp ?? internal;
const yearly = billing === "yearly";
const duration = reduceMotion ? 0 : rollDuration;
const radioRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = (next: BillingPeriod) => {
if (next === billing) return;
if (billingProp === undefined) setInternal(next);
onBillingChange?.(next);
};
/** Formatter per amount: whole numbers without decimals, others with the currency's usual digits. */
const format = React.useMemo(() => {
const make = (digits?: number) => {
try {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
...(digits === undefined ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits }),
});
} catch {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
}
};
const base = make();
const whole = make(0);
return (amount: number) => (Number.isInteger(amount) ? whole : base);
}, [currency, locale]);
const amountFor = (plan: PricingPlan) => {
if (!yearly) return plan.monthly;
return yearlyDisplay === "perMonth" ? Math.round((plan.yearly / 12) * 100) / 100 : plan.yearly;
};
const periodFor = () => (!yearly || yearlyDisplay === "perMonth" ? "/mo" : "/yr");
const savingFor = (plan: PricingPlan) =>
plan.monthly > 0 ? Math.round((1 - plan.yearly / (plan.monthly * 12)) * 100) : 0;
const bestSaving = Math.max(0, ...plans.map(savingFor));
const spoken = (plan: PricingPlan) => {
const amount = amountFor(plan);
const text = format(amount).format(amount);
if (!yearly) return `${text} per month`;
const saving = showSavings && savingFor(plan) > 0 ? `, saving ${savingFor(plan)}%` : "";
return yearlyDisplay === "perMonth"
? `${text} per month, billed yearly at ${format(plan.yearly).format(plan.yearly)}${saving}`
: `${text} per year${saving}`;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next: BillingPeriod =
event.key === "Home" ? "monthly" : event.key === "End" ? "yearly" : billing === "monthly" ? "yearly" : "monthly";
radioRefs.current[next === "monthly" ? 0 : 1]?.focus();
select(next);
};
const focusRing =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)]";
const springy = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 32, mass: 0.8 };
return (
<div className={`flex w-full flex-col items-center gap-8 ${className}`} style={{ ["--pt-accent" as string]: accent, ...style }} {...props}>
{/* Billing period: a two-option radio group with a sliding thumb. */}
<div
role="radiogroup"
aria-label="Billing period"
onKeyDown={handleKeyDown}
className="relative inline-flex rounded-full border border-zinc-900/10 bg-zinc-900/[0.04] p-1 dark:border-white/10 dark:bg-white/[0.05]"
>
{(["monthly", "yearly"] as const).map((period, i) => {
const checked = billing === period;
return (
<button
key={period}
ref={(el) => {
radioRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
onClick={() => select(period)}
className={`relative inline-flex h-9 items-center gap-2 rounded-full px-4 text-sm font-medium tracking-tight transition-colors duration-200 ${focusRing} ${
checked ? "text-zinc-950 dark:text-white" : "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}`}
>
{checked && (
<motion.span
aria-hidden="true"
layoutId={`${id}-billing`}
transition={springy}
className="absolute inset-0 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.05)] dark:bg-zinc-700 dark:shadow-[0_1px_2px_rgb(0_0_0/0.5),0_0_0_1px_rgb(255_255_255/0.07)]"
/>
)}
<span className="relative">{period === "monthly" ? monthlyLabel : yearlyLabel}</span>
{period === "yearly" && showSavings && bestSaving > 0 && (
<span className="relative rounded-full bg-[var(--pt-accent)] px-1.5 py-0.5 font-mono text-[10px] font-semibold leading-none text-zinc-950">
−{bestSaving}%
</span>
)}
</button>
);
})}
</div>
<ul className="m-0 grid w-full list-none grid-cols-[repeat(auto-fit,minmax(min(100%,15rem),1fr))] gap-4 p-0">
{plans.map((plan) => {
const amount = amountFor(plan);
const saving = savingFor(plan);
const highlighted = plan.highlighted;
const ctaClass = `mt-6 inline-flex h-10 w-full items-center justify-center rounded-lg text-sm font-medium tracking-tight transition-colors duration-150 ${focusRing} ${
highlighted
? "bg-[var(--pt-accent)] text-zinc-950 hover:brightness-95"
: "border border-zinc-900/15 bg-white text-zinc-900 hover:bg-zinc-50 dark:border-white/15 dark:bg-white/[0.04] dark:text-zinc-100 dark:hover:bg-white/[0.08]"
}`;
return (
<li key={plan.id} className="min-w-0">
<article
aria-labelledby={`${id}-${plan.id}`}
className={`relative flex h-full flex-col rounded-2xl border p-6 ${
highlighted
? "border-[var(--pt-accent)] bg-white shadow-[0_0_0_1px_var(--pt-accent),0_16px_40px_-24px_rgb(0_0_0/0.35)] dark:bg-zinc-900"
: "border-zinc-900/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.03]"
}`}
>
<div className="flex items-center justify-between gap-3">
<h3 id={`${id}-${plan.id}`} className="text-base font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">
{plan.name}
</h3>
{highlighted && (
<span className="rounded-full border border-[var(--pt-accent)] px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-900 dark:text-zinc-100">
{plan.badge ?? "Popular"}
</span>
)}
</div>
{plan.description && <p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">{plan.description}</p>}
<div className="mt-5 flex flex-wrap items-baseline gap-x-1.5 gap-y-2">
<span className="text-4xl font-semibold tracking-tight text-zinc-950 dark:text-white">
<RollingPrice value={amount} formatter={format(amount)} duration={duration} />
</span>
<span aria-hidden="true" className="font-mono text-xs text-zinc-500 dark:text-zinc-400">
{periodFor()}
</span>
<span className="sr-only">{spoken(plan)}</span>
</div>
<div aria-hidden="true" className="mt-1.5 flex min-h-5 flex-wrap items-center gap-2">
<p className="font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400">
{yearly ? (yearlyDisplay === "perMonth" ? `Billed ${format(plan.yearly).format(plan.yearly)} yearly` : "Billed yearly") : "Billed monthly"}
</p>
<AnimatePresence initial={false}>
{yearly && showSavings && saving > 0 && (
<motion.span
key="save"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, y: 4 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 24 }}
className="rounded-full bg-[var(--pt-accent)]/15 px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.1em] text-zinc-900 ring-1 ring-[var(--pt-accent)]/40 ring-inset dark:text-zinc-100"
>
Save {saving}%
</motion.span>
)}
</AnimatePresence>
</div>
<ul className="mt-5 flex list-none flex-col gap-2.5 border-t border-zinc-900/10 p-0 pt-5 dark:border-white/10">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-2.5 text-sm text-zinc-700 dark:text-zinc-300">
<span className={highlighted ? "text-[var(--pt-accent)]" : "text-zinc-400 dark:text-zinc-500"}>
<CheckIcon />
</span>
{feature}
</li>
))}
</ul>
<div className="mt-auto">
{plan.href ? (
<a href={plan.href} className={ctaClass}>
{plan.cta}
</a>
) : (
<button type="button" onClick={() => onSelectPlan?.(plan, billing)} className={ctaClass}>
{plan.cta}
</button>
)}
</div>
</article>
</li>
);
})}
</ul>
{/* Announces the new prices after the billing period changes (initial content is not read out). */}
<p className="sr-only" aria-live="polite">
{`${yearly ? yearlyLabel : monthlyLabel} billing. ${plans.map((plan) => `${plan.name}: ${spoken(plan)}.`).join(" ")}`}
</p>
</div>
);
}