{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pricing-calculator","type":"registry:component","title":"Pricing calculator","description":"Drag to your size and watch the price roll, the plan morph and, at the top, “Let’s talk”.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json","https://hairlineui.com/r/pricing-toggle.json"],"files":[{"path":"registry/pricing-calculator.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { PricingToggle } from \"./pricing-toggle\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * PRICING CALCULATOR: drag to your size, watch the price\n *\n *   drag      the thumb follows your finger; the fill and a\n *             value bubble ride with it and the price rolls as\n *             you go\n *   jump      click the track (or tap a mark) and the thumb is\n *             thrown there with a little give\n *   tiers     crossing a mark morphs the plan's name and lights\n *             the mark; the button's words follow\n *   contact   past the last tier the price folds away and the\n *             line becomes \"Let's talk\"\n *   yearly    with a discount, a Monthly/Yearly switch rolls every\n *             figure to its yearly price\n *\n * Bring your own formula: price(value) and plan(value). A real\n * slider: arrow keys, Page Up/Down, Home and End.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CalculatorMark {\n  value: number;\n  label: string;\n}\n\nexport interface PricingCalculatorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  min: number;\n  max: number;\n  step?: number;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Monthly price for a value, before any yearly discount. */\n  price: (value: number) => number;\n  /** The plan a value falls in, e.g. \"Growth\". */\n  plan?: (value: number) => string;\n  /** Ticks along the track, e.g. where tiers change. */\n  marks?: CalculatorMark[];\n  /** What's being counted, e.g. \"monthly active users\". */\n  unit?: string;\n  /** Intl.NumberFormat options for the value, e.g. { notation: \"compact\" }. */\n  valueFormat?: Intl.NumberFormatOptions;\n  currency?: string;\n  /** Evenly spaced orders of magnitude, for ranges like 1,000 to 1,000,000. */\n  scale?: \"linear\" | \"log\";\n  /** At or above this, show \"Let's talk\" instead of a price. */\n  contactFrom?: number;\n  /** e.g. 0.2 shows a Monthly/Yearly switch with \"Save 20%\". */\n  yearlyDiscount?: number;\n  /** The button under the price; its words follow the plan. */\n  cta?: { label: (plan: string) => string; contactLabel?: string; onClick?: (value: number, plan: string, billing: \"monthly\" | \"yearly\") => void };\n  label?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nexport function PricingCalculator({\n  min,\n  max,\n  step = 1,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  price,\n  plan = () => \"\",\n  marks = [],\n  unit = \"\",\n  valueFormat,\n  currency = \"USD\",\n  scale = \"linear\",\n  contactFrom,\n  yearlyDiscount,\n  cta,\n  label = \"Usage\",\n  className = \"\",\n  ...props\n}: PricingCalculatorProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [own, setOwn] = React.useState(defaultValue ?? min);\n  const value = valueProp ?? own;\n  const [billing, setBilling] = React.useState<\"monthly\" | \"yearly\">(\"monthly\");\n  const [dragging, setDragging] = React.useState(false);\n  const [thrown, setThrown] = React.useState(false);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const thumbRef = React.useRef<HTMLDivElement>(null);\n  const pointer = React.useRef<number | null>(null);\n\n  // Value ↔ position along the track, linear or by orders of magnitude.\n  const toRatio = React.useCallback(\n    (v: number) => {\n      if (scale === \"log\" && min > 0) return (Math.log(v) - Math.log(min)) / (Math.log(max) - Math.log(min));\n      return (v - min) / (max - min);\n    },\n    [scale, min, max]\n  );\n  const fromRatio = React.useCallback(\n    (r: number) => {\n      const c = Math.min(1, Math.max(0, r));\n      const raw = scale === \"log\" && min > 0 ? Math.exp(Math.log(min) + c * (Math.log(max) - Math.log(min))) : min + c * (max - min);\n      // On a log scale, round to a step that suits the size (1,000s near 10k, 10,000s near 100k).\n      const s = scale === \"log\" ? Math.max(step, 10 ** Math.max(0, Math.floor(Math.log10(raw)) - 1)) : step;\n      return Math.min(max, Math.max(min, Math.round(raw / s) * s));\n    },\n    [scale, min, max, step]\n  );\n\n  const set = (next: number) => {\n    const v = Math.min(max, Math.max(min, next));\n    if (valueProp === undefined) setOwn(v);\n    onValueChange?.(v);\n  };\n\n  const fromPointer = (clientX: number) => {\n    const track = trackRef.current;\n    if (!track) return value;\n    const r = track.getBoundingClientRect();\n    return fromRatio((clientX - r.left) / r.width);\n  };\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (e.button !== 0) return;\n    e.preventDefault();\n    pointer.current = e.pointerId;\n    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n    thumbRef.current?.focus({ preventScroll: true });\n    // A press away from the thumb throws it there; a press on it starts a drag.\n    const onThumb = (e.target as HTMLElement).closest(\"[data-thumb]\");\n    setThrown(!onThumb);\n    setDragging(Boolean(onThumb));\n    set(fromPointer(e.clientX));\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    if (pointer.current !== e.pointerId) return;\n    if (!dragging) {\n      setThrown(false);\n      setDragging(true);\n    }\n    set(fromPointer(e.clientX));\n  };\n  const onPointerUp = (e: React.PointerEvent) => {\n    if (pointer.current !== e.pointerId) return;\n    pointer.current = null;\n    setDragging(false);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const r = toRatio(value);\n    const nudge = (d: number) => {\n      // Each press moves at least one visible step, even where a log scale is coarse.\n      const next = fromRatio(r + d);\n      return next === value ? value + Math.sign(d) * step : next;\n    };\n    const moves: Record<string, () => number> = {\n      ArrowRight: () => nudge(0.01),\n      ArrowUp: () => nudge(0.01),\n      ArrowLeft: () => nudge(-0.01),\n      ArrowDown: () => nudge(-0.01),\n      PageUp: () => nudge(0.1),\n      PageDown: () => nudge(-0.1),\n      Home: () => min,\n      End: () => max,\n    };\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    setThrown(e.key === \"Home\" || e.key === \"End\" || e.key.startsWith(\"Page\"));\n    set(moves[e.key]());\n  };\n\n  const ratio = Math.min(1, Math.max(0, toRatio(value)));\n  const contact = contactFrom !== undefined && value >= contactFrom;\n  const planName = plan(value);\n  const discount = billing === \"yearly\" && yearlyDiscount ? yearlyDiscount : 0;\n  const monthly = price(value) * (1 - discount);\n  const currencyFormat = React.useMemo<Intl.NumberFormatOptions>(() => ({ style: \"currency\", currency, maximumFractionDigits: 0 }), [currency]);\n  const valueText = new Intl.NumberFormat(undefined, valueFormat).format(value);\n  const moveTransition = reduced || dragging ? \"none\" : thrown ? `transform 520ms ${THROW}` : `transform 220ms ${EASE}`;\n  const fillTransition = reduced || dragging ? \"none\" : thrown ? `transform 520ms ${THROW}` : `transform 220ms ${EASE}`;\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <div className=\"flex flex-wrap items-end justify-between gap-4\">\n        <div className=\"min-w-0\">\n          {/* The plan's name morphs as you cross a tier. */}\n          <p className=\"h-5 text-[13px] font-medium text-primary\">\n            <TextMorph>{contact ? \"Enterprise\" : planName}</TextMorph>\n          </p>\n          <div className=\"mt-1 flex h-[60px] items-baseline\">\n            <span\n              className=\"grid\"\n              style={{\n                gridTemplateColumns: contact ? \"0fr\" : \"1fr\",\n                opacity: contact ? 0 : 1,\n                filter: contact ? \"blur(6px)\" : \"none\",\n                transition: reduced ? \"none\" : `grid-template-columns 460ms ${EASE}, opacity 300ms ${EASE}, filter 300ms ${EASE}`,\n              }}\n            >\n              <span className=\"flex min-w-0 items-baseline gap-1 overflow-hidden whitespace-nowrap\">\n                <NumberRoll value={Math.round(monthly)} format={currencyFormat} className=\"text-[52px] font-medium leading-none tracking-[-0.04em] text-foreground\" />\n                <span className=\"text-[15px] text-muted-foreground\">/mo</span>\n              </span>\n            </span>\n            <span\n              className=\"grid\"\n              style={{\n                gridTemplateColumns: contact ? \"1fr\" : \"0fr\",\n                opacity: contact ? 1 : 0,\n                filter: contact ? \"none\" : \"blur(6px)\",\n                transition: reduced ? \"none\" : `grid-template-columns 460ms ${EASE}, opacity 300ms ${EASE} 120ms, filter 300ms ${EASE} 120ms`,\n              }}\n            >\n              <span className=\"min-w-0 overflow-hidden whitespace-nowrap text-[52px] font-medium leading-none tracking-[-0.04em] text-foreground\">Let’s talk</span>\n            </span>\n          </div>\n          <p className=\"mt-2 min-h-5 text-[13px] leading-5 text-muted-foreground\">\n            <TextMorph animateWidth={false}>\n              {contact\n                ? \"Volume pricing, SSO and a named contact\"\n                : discount\n                  ? `${new Intl.NumberFormat(undefined, currencyFormat).format(Math.round(monthly * 12))} billed yearly`\n                  : \"Billed monthly, cancel anytime\"}\n            </TextMorph>\n          </p>\n        </div>\n        {yearlyDiscount ? (\n          <PricingToggle\n            value={billing}\n            onValueChange={(v) => setBilling(v as \"monthly\" | \"yearly\")}\n            options={[\n              { value: \"monthly\", label: \"Monthly\" },\n              { value: \"yearly\", label: \"Yearly\", badge: `Save ${Math.round(yearlyDiscount * 100)}%` },\n            ]}\n          />\n        ) : null}\n      </div>\n\n      {/* The slider. */}\n      <div className=\"mt-10 select-none\">\n        <div\n          ref={trackRef}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={onPointerUp}\n          className=\"relative flex h-10 cursor-pointer touch-none items-center\"\n        >\n          <div className=\"absolute inset-x-0 h-1.5 overflow-hidden rounded-full bg-muted shadow-[inset_0_0_0_1px_var(--border)]\">\n            <div\n              className=\"h-full origin-left rounded-full bg-primary\"\n              style={{ transform: `scaleX(${ratio})`, transition: fillTransition }}\n            />\n          </div>\n          {marks.map((m) => {\n            const r = toRatio(m.value);\n            const passed = value >= m.value;\n            return (\n              <span\n                key={m.value}\n                aria-hidden=\"true\"\n                className=\"absolute top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-[background-color,transform] duration-300\"\n                style={{ left: `${r * 100}%`, background: passed ? \"var(--primary-foreground)\" : \"color-mix(in oklab, var(--foreground) 22%, transparent)\" }}\n              />\n            );\n          })}\n          {/* The thumb, with the value riding above it. */}\n          <div\n            className=\"pointer-events-none absolute inset-y-0 left-0 w-full\"\n            style={{ transform: `translateX(${ratio * 100}%)`, transition: moveTransition }}\n          >\n            <div\n              ref={thumbRef}\n              data-thumb=\"\"\n              role=\"slider\"\n              tabIndex={0}\n              aria-label={label}\n              aria-valuemin={min}\n              aria-valuemax={max}\n              aria-valuenow={value}\n              aria-valuetext={`${valueText}${unit ? ` ${unit}` : \"\"}${contact ? \", contact sales\" : `, ${new Intl.NumberFormat(undefined, currencyFormat).format(Math.round(monthly))} a month`}`}\n              aria-describedby={`${id}-plan`}\n              onKeyDown={onKeyDown}\n              className={`pointer-events-auto absolute top-1/2 flex size-6 -translate-x-1/2 -translate-y-1/2 cursor-grab items-center justify-center rounded-full bg-background shadow-[0_0_0_1px_var(--border)] outline-none transition-[scale] duration-200 focus-visible:ring-2 focus-visible:ring-ring/40 ${\n                dragging ? \"scale-110 cursor-grabbing\" : \"\"\n              }`}\n            >\n              <span className=\"size-2 rounded-full bg-primary\" />\n              {/* Centred over the thumb mid-track, leaning inwards at the ends so it never leaves the track. */}\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute bottom-full left-1/2 mb-2.5 flex h-7 items-baseline whitespace-nowrap rounded-full bg-background px-2.5 pt-[5px] text-[12.5px] font-medium tabular-nums text-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n                style={{ transform: `translateX(${-ratio * 100}%)`, transition: moveTransition }}\n              >\n                <NumberRoll value={value} format={valueFormat} duration={dragging ? 300 : 600} />\n                {unit && <span className=\"ml-1 font-normal text-muted-foreground\">{unit.split(\" \").slice(-1)[0]}</span>}\n              </span>\n            </div>\n          </div>\n        </div>\n        {marks.length > 0 && (\n          <div className=\"relative mt-1 h-5 text-[11.5px]\">\n            {marks.map((m) => (\n              <button\n                key={m.value}\n                type=\"button\"\n                tabIndex={-1}\n                onClick={() => {\n                  setThrown(true);\n                  set(m.value);\n                }}\n                className={`absolute -translate-x-1/2 whitespace-nowrap transition-colors duration-300 hover:text-foreground ${value >= m.value ? \"text-foreground\" : \"text-muted-foreground\"}`}\n                style={{ left: `${toRatio(m.value) * 100}%` }}\n              >\n                {m.label}\n              </button>\n            ))}\n          </div>\n        )}\n      </div>\n\n      {cta && (\n        <div className=\"mt-8\">\n          <button\n            type=\"button\"\n            onClick={() => cta.onClick?.(value, contact ? \"Enterprise\" : planName, billing)}\n            className={`inline-flex h-10 items-center justify-center rounded-full px-5 text-[13.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-300 active:scale-[0.97] ${FOCUS} ${\n              contact ? \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\" : \"bg-primary text-primary-foreground hover:bg-primary/90\"\n            }`}\n          >\n            <TextMorph>{contact ? (cta.contactLabel ?? \"Contact sales\") : cta.label(planName)}</TextMorph>\n          </button>\n        </div>\n      )}\n      <p id={`${id}-plan`} className=\"sr-only\" aria-live=\"polite\">\n        {contact ? \"Contact sales for this size\" : `${planName} plan`}\n      </p>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/pricing-calculator"}