Pricing calculator
Explains changing price through quantity, tier, and billing interval.
src/registry/components/pricing-calculator.tsx
"use client";
import { useId, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
export type PricingTier = { id: string; label: string; unitPrice: number; description?: string };
export type PricingCalculatorProps = { tiers: readonly PricingTier[]; currency?: string; label?: string; className?: string; onChange?: (total: number) => void };
export function PricingCalculator({ tiers, currency = "USD", label = "Pricing calculator", className = "", onChange }: PricingCalculatorProps) {
const reduced = useReducedMotion() === true; const id = useId(); const [tierIndex, setTierIndex] = useState(0); const [quantity, setQuantity] = useState(12); const [annual, setAnnual] = useState(true); const tier = tiers[tierIndex]; const subtotal = (tier?.unitPrice ?? 0) * quantity; const discount = annual ? Math.round(subtotal * .18) : 0; const total = subtotal - discount; const money = useMemo(() => new Intl.NumberFormat("en", { style: "currency", currency, maximumFractionDigits: 0 }), [currency]);
const updateQuantity = (next: number) => { const safe = Math.max(1, Math.min(250, next)); setQuantity(safe); onChange?.(((tiers[tierIndex]?.unitPrice ?? 0) * safe) * (annual ? .82 : 1)); };
return <section aria-labelledby={id} className={`rounded-[16px] border border-black/[.1] bg-white p-4 shadow-[0_14px_34px_-27px_rgba(31,31,31,.38)] ${className}`}><div className="flex items-start justify-between gap-3"><div><span className="font-mono text-[10px] uppercase tracking-[.16em] text-neutral-500">{label}</span><h3 id={id} className="mt-1 text-[15px] font-medium text-[#292929]">Build your plan</h3></div><button type="button" aria-pressed={annual} onClick={() => setAnnual((value) => !value)} className="min-h-11 rounded-full border border-black/[.1] px-3 text-[10px] outline-none hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-[#4568FF]">{annual ? "Annual · 18% off" : "Monthly"}</button></div><div role="radiogroup" aria-label="Pricing tier" className="mt-5 grid grid-cols-3 gap-1.5">{tiers.map((item, index) => <button key={item.id} type="button" role="radio" aria-checked={index === tierIndex} onClick={() => { setTierIndex(index); onChange?.((item.unitPrice * quantity) * (annual ? .82 : 1)); }} className={`min-h-11 rounded-lg border px-2 text-[11px] outline-none focus-visible:ring-2 focus-visible:ring-[#4568FF] ${index === tierIndex ? "border-[#242424] bg-[#242424] text-white" : "border-black/[.1] text-neutral-600"}`}>{item.label}</button>)}</div><label className="mt-5 block text-[11px] text-neutral-600" htmlFor={`${id}-quantity`}>Seats <span className="float-right font-mono">{quantity}</span><input id={`${id}-quantity`} type="range" min="1" max="250" value={quantity} onChange={(event) => updateQuantity(Number(event.target.value))} className="mt-2 w-full accent-[#242424]" /></label><dl className="mt-4 space-y-1.5 border-t border-black/[.08] pt-3 text-[11px] text-neutral-600"><div className="flex justify-between"><dt>{tier?.label ?? "Plan"} × {quantity}</dt><dd>{money.format(subtotal)}</dd></div>{annual ? <div className="flex justify-between text-emerald-700"><dt>Annual saving</dt><dd>−{money.format(discount)}</dd></div> : null}<div className="flex justify-between border-t border-black/[.08] pt-2 text-[14px] font-medium text-[#292929]"><dt>Total / month</dt><motion.dd key={total} initial={reduced ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }}>{money.format(total)}</motion.dd></div></dl></section>;
}
- Where it goes
- pricing
- Use cases
- saas-landing
DependenciesBundle weight · medium
- animation
- motion