Pricing Calculator
An interactive estimator whose billable axes re-derive the bill on every change — switching tier by itself when a cap is crossed, and naming the cheaper plan when another one wins.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pricing-calculator.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PricingCalculator" block with lucide-react
and cn() (clsx + tailwind-merge); no other dependency. It answers one question —
"what would this cost me" — for pricing that is a combination of seats, metered
usage and add-ons, where which tier is cheapest depends on the numbers the
visitor types.
Contract
- Everything priceable arrives in one `config` prop. The component hard-codes no
price, no plan name, no cap and no discount:
PricingConfig = { currency (ISO 4217); plans: PricingPlan[] (ascending order);
axes: PricingAxis[]; annual?: { discountPercent; note? } }.
PricingAxis = { id; label; unit; unitPlural?; control: "slider"|"stepper"|
"toggle"; min; max; step; defaultValue; hint? }.
PricingPlan = { id; name; tagline?; baseMinorUnits; rates?: Record<axisId,
{ included?; block?; pricePerBlock }>; limits?: Record<axisId, number> }.
- Money rule: every amount is an INTEGER in the currency's minor unit (700 is
$7.00 under USD and 700 yen under JPY). Divide by the digit count that
Intl.NumberFormat.resolvedOptions() reports, never by a literal 100 — JPY has
no minor unit and KWD has three digits.
- Quantity rule: every axis value is an integer count on its own step grid. No
float ever enters the arithmetic, so no printed total can carry a float tail.
- An axis with no rate on a plan is bundled at no charge. A limit of 0 means the
plan does not offer that axis at all; any limit means quantities above it make
the plan unavailable rather than expensive.
- Props: config, title, description, defaultQuantities, defaultPeriod,
defaultPlanId (null keeps it on best-match), locale = "en-US",
onQuoteChange?(quote|null), onCheckout?(quote), ctaLabel, className, ref,
and the rest spread onto the root <section>. Everything the visitor moves is
uncontrolled internal state; the host ships rules and receives quotes.
- Export the pure core so a server route, a test or a checkout session can quote
the same numbers: clampQuantity, clampQuantities, planBreach, axisCeiling,
quoteFor and rankPlans. None of them touch React, the DOM or a clock.
Behavior
- Pricing maths, per plan: amount = base + sum over axes of
ceil(max(0, quantity − included) / block) × pricePerBlock. Metered usage bills
whole blocks and a partial block rounds UP — 30,001 events over a 10,000 block
is four blocks. Say so in the copy.
- Annual: perMonth = round(monthly × (100 − discount) / 100) and the invoice is
perMonth × 12. One rounding point, so the two printed numbers always agree:
twelve times the stated month equals the stated year exactly. State the
discount and the saving in words, never just a struck-through number.
- Plan selection is DERIVED first: price every plan against the current
quantities, drop the ones that breach a limit, rank the rest cheapest-first
(a stable sort, so equal prices resolve to the lower tier listed first) and
quote the winner. Crossing a threshold therefore switches tier by itself.
- A pin (choosing a plan chip) overrides the derivation only while it still
fits. Outgrow it and the estimate moves to a plan that fits and says which cap
did it; the pin is REMEMBERED, not erased, so stepping back under the cap
restores the plan the visitor chose. Never silently keep quoting a plan that
cannot be sold.
- Cheaper-tier suggestion: when a pinned plan still fits but another eligible
plan prices lower, show one banner naming the driving axis — the axis whose
money differs most between the two plans — as "At 70 seats, Business costs
$30.00 less per month than Team", with a button that switches.
- Every plan chip carries its own live price for the current configuration, and
an unavailable chip carries its cap instead ("Up to 10 seats", "No SSO").
- Refusals are explained, never disabled into silence. Pressing an unavailable
plan sets a role="alert" sentence and changes nothing. When no plan can serve
the configuration, show no invented price: name the axis and the largest cap
any plan offers ("No plan covers 400 seats — the largest, Business, tops out
at 250"), and repeat that on the control that has to move.
- The CTA is painted only when onCheckout exists. With no quote it is
aria-disabled AND guarded, and the press produces the explanation instead of a
checkout. With a quote it enters onCheckout once per press-burst: the guard is
a ref read and written synchronously inside the handler, because a state flag
is only visible after a re-render and the second click of a double click lands
before that. A timer re-arms it; clear that timer on unmount.
- onQuoteChange fires on value changes, not on renders: key the effect on a
signature string built from the quote's numbers and keep the callback in a ref,
so an inline arrow prop and a parent that stores the quote cannot loop.
- Keyboard. Slider thumb (role=slider): Arrow keys ±step, PageUp/PageDown ±10
steps, Home/End the axis bounds. Stepper (role=spinbutton plus two buttons):
the same map, and the buttons go aria-disabled at the bounds with a handler
guard — never the native attribute, or focus is thrown to <body> the moment a
bound is reached. Chips are radiogroups with roving tabindex: Arrow keys move
and select, Home/End jump; when nothing is checked the FIRST chip keeps the tab
stop, so an unsellable configuration cannot drop the group out of the tab
order. Add-ons are role=switch.
- Pointer: the slider captures the pointer, keeps the grab offset so grabbing the
thumb does not teleport by half its width, treats buttons === 0 during a move
as a lost pointerup, and releases capture on up and cancel.
- One live region (role=status, aria-atomic) speaks only when the ANSWER changes
— plan, billing period, or a pin being outgrown — after a short debounce, so
dragging a slider across three thresholds announces once instead of on every
pixel. The first paint is the baseline and is never announced. Clear that
timer on unmount and whenever the phase changes again.
- Anything that unmounts under the visitor's finger hands focus on: taking the
suggestion, or pressing "Use best match", moves focus to the plan chip that now
holds the answer, synchronously in the handler, because the chips are always
mounted.
- No clock, no Date.now, no random: the same config plus the same quantities
render the same bill on the server and in the browser.
Rendering & styling
- Semantic tokens only: bg-card panels, border/divide-y structure,
text-muted-foreground for supporting copy, bg-primary for the checked chip
ring, the slider fill and the CTA, bg-input for an off switch,
destructive/40 + destructive/10 for the refusal alert, primary/30 +
primary/5 for the suggestion banner. No hex, rgb or oklch anywhere.
- Every figure is tabular-nums; long plan names and long labels use wrap-anywhere
so a column can never push the block sideways.
- Layout: controls and summary side by side from lg
(grid-cols-[minmax(0,1fr)_minmax(0,22rem)], items-start), stacked below it.
- Motion is decoration: slider fill and thumb transition on width/left with
motion-reduce:transition-none, and transitions are switched off entirely
while dragging so the thumb tracks the finger. Nothing depends on animation.
- Accessibility: each control is labelled by its row label id and described by
its rate sentence id; the slider carries aria-valuemin/max/now plus a human
aria-valuetext ("70 seats"); focus-visible rings on every interactive element;
decorative icons are aria-hidden. cn() merges the consumer's className.
Customization levers
- Axes: add or remove rows in config.axes — the block prices, labels and renders
whatever it is given. Switch a row between slider, stepper and toggle by its
control field alone; steppers suit small counts, sliders wide ranges.
- Tiers: any number of plans. Drop config.annual entirely and the billing-period
control disappears with it; move discountPercent onto the plan and read it in
quoteFor for per-tier discounts.
- Selection policy: rankPlans decides "best". Rank by billedMinorUnits instead of
perMonthMinorUnits, or bias toward a marketed tier by comparing with a
tolerance, without touching the view.
- Density: the summary is a sibling of the control panel — move it above,
stick it to the viewport bottom on mobile, or drop the CTA by omitting
onCheckout.
- Emphasis: the checked chip is border-primary + ring-1; raise it to shadow-lg or
a badge for a louder recommendation. The suggestion banner and the refusal
alert are separate blocks and can be relocated independently.
- Copy: every sentence (best match, moved because, suggestion, billing, refusal)
is built in one place from the same numbers — rewrite the wording without
touching the arithmetic that produced it.Concepts
- Config-driven pricing — plans, rates, included allowances, caps and the discount all arrive as data in integer minor units; the block contains no price, so the same code prices a per-seat SaaS in dollars and a per-call API in yen.
- Derived selection, then pinning — the quoted plan is computed (cheapest that fits) before anyone chooses one, which is what makes crossing a threshold switch tier by itself; a pin overrides that only while it still fits and is remembered, not destroyed, when it stops fitting.
- Cheaper-tier suggestion — when the visitor overshoots the boundary where a higher tier wins, the block names the axis and the exact monthly difference instead of leaving the arithmetic to the reader.
- Refusal instead of an invented price — a configuration past every cap produces no number at all: the largest cap is named, the control that must move says so, and the CTA refuses the press while staying focusable.
- Announce the answer, not the pixels — prices change on every pointer move, so the live region speaks only when the plan, the period or a cap-crossing changes, after a debounce that survives a drag across three thresholds.
- One rounding point — the annual discount is applied once, to the monthly figure, and the invoice is twelve of those; the printed month and the printed year can then never disagree by a cent.