Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Minus, Plus, TrendingDown, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** PageUp / PageDown move ten ordinary steps at once. */
const PAGE_MULTIPLIER = 10
/** A plan switch is announced this long after the last change, so a drag speaks once. */
const ANNOUNCE_DELAY_MS = 350
/** How long the CTA stays locked after a press, so one burst enters onCheckout once. */
const CHECKOUT_BURST_MS = 900

/* ------------------------------------------------------------------- types */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pricing-calculator.json

Prompt

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.

On This Page