Pricing Calculator
NewInstallation
npx shadcn@latest add https://uibrik.com/r/pricing-calculator.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/pricing-calculator/pricing-calculator.tsx"use client"; import { useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; import { Badge, BillingToggle, EASE_OUT, FOCUS, FeatureRow, Motion, PANEL, PlanButton, PlanCard, PlanPrice, SHELL, formatMoney, type Billing, type MoneyFormat, } from "../_pricing/parts"; // Pricing calculator. Instead of asking people to compare plans, ask how big // the team is. Drag the seat count and every total updates as you go; plans // that can't hold that many people ease back with the reason, and the plan // that costs least for that team is marked "Best for 8 seats", with what it // saves over the next option. Dots on the slider mark the seat counts where // a different plan becomes cheapest, so the pricing explains itself as you // drag. They move when the billing period changes, because the break-even // points do. export type SeatPlan = { name: string; blurb: string; /** Price per month, billed monthly or yearly: per seat, or for the whole plan. */ price: { monthly: number; yearly: number }; per: "seat" | "plan"; /** Seats billed at the least. */ minSeats?: number; /** Most seats the plan holds. Leave out for no limit. */ maxSeats?: number; features: string[]; cta: string; }; export const SEAT_PLANS: SeatPlan[] = [ { name: "Solo", blurb: "One person, every tool.", price: { monthly: 8, yearly: 6 }, per: "plan", maxSeats: 1, features: ["All components", "Unlimited projects", "Email support"], cta: "Go solo", }, { name: "Team", blurb: "Small teams that move fast.", price: { monthly: 10, yearly: 8 }, per: "seat", maxSeats: 50, features: ["Shared library", "Roles and reviews", "Priority support"], cta: "Start a team", }, { name: "Business", blurb: "Bigger teams, better rates.", price: { monthly: 8, yearly: 6 }, per: "seat", minSeats: 15, features: ["SSO and audit log", "Usage insights", "Dedicated manager"], cta: "Talk to sales", }, ]; export type PricingCalculatorProps = MoneyFormat & { /** Two plans drops the first one (Solo). */ tiers?: "2" | "3"; /** Seat count on mount. */ defaultSeats?: number; /** Highest seat count the slider reaches. */ maxSeats?: number; /** Billing period on mount. */ defaultBilling?: Billing; plans?: readonly SeatPlan[]; onSelect?: (plan: SeatPlan, seats: number, billing: Billing) => void; className?: string; }; const billedSeats = (p: SeatPlan, seats: number) => (p.per === "plan" ? 1 : Math.max(seats, p.minSeats ?? 1)); const cost = (p: SeatPlan, seats: number, b: Billing) => p.price[b] * billedSeats(p, seats); const fits = (p: SeatPlan, seats: number) => seats <= (p.maxSeats ?? Infinity); /** Plans that hold this many seats, cheapest first. */ const ranked = (plans: readonly SeatPlan[], seats: number, b: Billing) => plans.filter((p) => fits(p, seats)).sort((x, y) => cost(x, seats, b) - cost(y, seats, b)); /** Seat counts where the cheapest plan changes, with the plan it changes to. */ const breakpoints = (plans: readonly SeatPlan[], max: number, b: Billing) => { const out: { seats: number; plan: string }[] = []; let prev = ranked(plans, 1, b)[0]; for (let n = 2; n <= max; n++) { const best = ranked(plans, n, b)[0]; if (best && best !== prev) out.push({ seats: n, plan: best.name }); prev = best; } return out; }; /** Whole percent saved by paying yearly, across the plans. */ const yearlySaving = (plans: readonly SeatPlan[]) => Math.round(Math.max(0, ...plans.map((p) => 1 - p.price.yearly / p.price.monthly)) * 100); export function PricingCalculator({ tiers = "3", defaultSeats = 8, maxSeats = 50, defaultBilling = "monthly", plans = SEAT_PLANS, currency = "USD", locale = "en-US", onSelect, className, }: PricingCalculatorProps) { const shown = tiers === "2" ? plans.slice(-2) : plans.slice(-3); const [seats, setSeats] = useState(Math.min(Math.max(1, defaultSeats), maxSeats)); const [billing, setBilling] = useState<Billing>(defaultBilling); const money = { currency, locale }; const [best, runnerUp] = ranked(shown, seats, billing); const saving = best && runnerUp ? cost(runnerUp, seats, billing) - cost(best, seats, billing) : 0; return ( <Motion> <section className={cn("@container flex w-full flex-col items-center gap-8", className)}> <div className={cn(SHELL, "w-full max-w-lg")}> <div className="flex min-h-11 flex-wrap items-center justify-between gap-2 pb-1.5 pl-3"> <h3 className="text-sm font-medium text-foreground">How big is your team?</h3> <BillingToggle value={billing} onChange={setBilling} saving={yearlySaving(shown)} /> </div> <div className={cn(PANEL, "p-1.5")}> <SeatSlider value={seats} max={maxSeats} onChange={setSeats} stops={breakpoints(shown, maxSeats, billing)} best={best?.name} /> </div> </div> <div className={cn( "grid w-full gap-3", shown.length === 2 ? "max-w-3xl @2xl:grid-cols-2" : "max-w-5xl @3xl:grid-cols-3", )} > {shown.map((plan) => { const ok = fits(plan, seats); const isBest = plan === best; const billed = billedSeats(plan, seats); const detail = !ok ? `Up to ${plan.maxSeats} seat${plan.maxSeats === 1 ? "" : "s"}` : plan.per === "plan" ? "Flat price" : billed > seats ? `Billed for ${billed} seats minimum` : `${formatMoney(plan.price[billing], money)} per seat × ${seats}`; return ( <motion.div key={plan.name} className="flex" initial={false} animate={{ opacity: ok ? 1 : 0.45 }} transition={{ duration: 0.25, ease: "easeOut" }} > <PlanCard name={plan.name} className={cn("w-full transition-colors duration-200", isBest && "bg-muted dark:bg-muted")} badge={ <AnimatePresence initial={false}> {isBest && ( <motion.span initial={{ opacity: 0, y: 4, filter: "blur(2px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -4, filter: "blur(2px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} > <Badge> Best for {seats} seat{seats === 1 ? "" : "s"} </Badge> </motion.span> )} </AnimatePresence> } > <p className="text-sm text-muted-foreground">{plan.blurb}</p> <PlanPrice value={cost(plan, seats, billing)} {...money} className="mt-4" /> <p className="mt-1 h-5 text-sm tabular-nums text-muted-foreground">{detail}</p> {/* one line held open on every card, so buttons stay level */} <p className="mt-0.5 h-5 text-sm font-medium tabular-nums text-emerald-600 dark:text-emerald-400"> {isBest && saving > 0 && `Saves ${formatMoney(saving, money)} / month over ${runnerUp.name}`} </p> <PlanButton primary={isBest} disabled={!ok} className="mt-5" onClick={() => onSelect?.(plan, seats, billing)}> {plan.cta} </PlanButton> <ul className="mt-6 flex flex-col gap-2.5 border-t border-border pt-5"> {plan.features.map((f) => ( <FeatureRow key={f}>{f}</FeatureRow> ))} </ul> </PlanCard> </motion.div> ); })} </div> </section> </Motion> ); } // ─── Seat slider ───────────────────────────────────────────────────────────── // The Customize panel's slider tile: fill from the left ending in a handle, // ticks between label and value, plus dots under the track at the seat counts // that matter. const TICKS = 8; // The fill never ends left of this, so its handle stays clear of the label. const REST = 72; function SeatSlider({ value, max, onChange, stops, best, }: { value: number; max: number; onChange: (v: number) => void; /** Seat counts where the cheapest plan changes. */ stops: { seats: number; plan: string }[]; /** Cheapest plan at the current value, for screen readers. */ best?: string; }) { const track = useRef<HTMLDivElement>(null); const [dragging, setDragging] = useState(false); const share = (v: number) => (max === 1 ? 1 : (v - 1) / (max - 1)); const at = (v: number) => `calc(${REST}px + (100% - ${REST}px) * ${share(v)})`; const set = (v: number) => onChange(Math.min(max, Math.max(1, Math.round(v)))); const fromPointer = (x: number) => { const r = track.current!.getBoundingClientRect(); set(1 + ((x - r.left - REST) / (r.width - REST)) * (max - 1)); }; return ( <div ref={track} role="slider" tabIndex={0} aria-label="Seats" aria-valuemin={1} aria-valuemax={max} aria-valuenow={value} aria-valuetext={`${value} seat${value === 1 ? "" : "s"}${best ? `, ${best} is best` : ""}`} onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setDragging(true); fromPointer(e.clientX); }} onPointerMove={(e) => dragging && fromPointer(e.clientX)} onPointerUp={() => setDragging(false)} onPointerCancel={() => setDragging(false)} onKeyDown={(e) => { const big = e.shiftKey ? 10 : 1; const keys: Record<string, number> = { ArrowRight: value + big, ArrowUp: value + big, ArrowLeft: value - big, ArrowDown: value - big, Home: 1, End: max, }; if (e.key in keys) { e.preventDefault(); set(keys[e.key]); } }} className={cn( "edge-tile relative flex h-12 cursor-ew-resize touch-none select-none items-center justify-between gap-3 overflow-hidden rounded-xl bg-muted px-4 text-sm", FOCUS, )} > <div aria-hidden className={cn("absolute inset-y-0 left-0 bg-black/[0.045] dark:bg-white/[0.06]", !dragging && "transition-[width] duration-150 ease-out")} style={{ width: at(value) }} > <span className={cn( "absolute right-2 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-full bg-zinc-400 transition-colors dark:bg-zinc-500", dragging && "bg-zinc-600 dark:bg-zinc-300", )} /> </div> {/* where a different plan takes over; the dot darkens once passed */} {stops.map((s, i) => ( <span key={i} aria-hidden title={`${s.plan} from ${s.seats} seats`} className={cn( "absolute bottom-1.5 size-1 -translate-x-1/2 rounded-full transition-[left,background-color] duration-300 ease-out", value >= s.seats ? "bg-zinc-500 dark:bg-zinc-400" : "bg-zinc-300 dark:bg-zinc-700", )} style={{ left: `calc(${at(s.seats)} - 9.5px)` }} /> ))} <span className="relative shrink-0 text-zinc-600 dark:text-zinc-400">Seats</span> {/* ticks fill the space between label and value, so they never cross the text */} <span aria-hidden className="pointer-events-none relative flex min-w-0 flex-1 items-center justify-evenly"> {Array.from({ length: TICKS }, (_, i) => ( <span key={i} className="h-2 w-px bg-zinc-300/70 dark:bg-zinc-700/80" /> ))} </span> <span className="relative w-8 shrink-0 text-right font-medium tabular-nums text-foreground">{value}</span> </div> ); }
Adds the files to
components/uibrik/pricing-calculator and installs @number-flow/react, @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/pricing-calculator.Props
PricingCalculator- tiers
- Type "2" | "3"
- "3"
- How many plans to show. Two drops the first (Solo).
- defaultSeats
- Type number
- 8
- Seat count on mount.
- defaultBilling
- Type "monthly" | "yearly"
- "monthly"
- Billing period on mount.
- maxSeats
- Type number
- 50
- Highest seat count the slider reaches.
- currency
- Type string
- "USD"
- ISO currency code for the prices.
- locale
- Type string
- "en-US"
- Locale for currency symbols, grouping and decimals.
- plans
- Type SeatPlan[]
- –
- Plans with a monthly and yearly price per seat or per plan, and an optional seat minimum and limit.
- onSelect
- Type (plan: SeatPlan, seats: number, billing: Billing) => void
- –
- Called when a plan's button is pressed.
- className
- Type string
- –
- Classes for the section.