{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pricing-calculator","type":"registry:component","title":"Pricing calculator","description":"Itemizes your bill like an invoice and upgrades the plan the moment your team crosses a seat threshold.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/pricing-calculator.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Plan = {\n  name: string;\n  // The plan applies from this many seats up, until the next plan's minimum.\n  minSeats: number;\n  // Per seat, per month, before the yearly discount.\n  seatPrice: number;\n};\n\ntype Billing = \"monthly\" | \"yearly\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst BLUR_IN = {\n  initial: { opacity: 0, y: 4, filter: \"blur(4px)\" },\n  animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: { opacity: 0, y: -2, filter: \"blur(2px)\" },\n};\n\nfunction planFor(plans: Plan[], seats: number) {\n  let found = plans[0];\n  for (const plan of plans) if (seats >= plan.minSeats) found = plan;\n  return found;\n}\n\nfunction money(value: number, cents = true) {\n  return value.toLocaleString(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n    minimumFractionDigits: cents ? 2 : 0,\n    maximumFractionDigits: cents ? 2 : 0,\n  });\n}\n\nexport function PricingCalculator({\n  plans,\n  maxSeats = 50,\n  defaultSeats = 6,\n  yearlyDiscount = 0.2,\n  className,\n}: {\n  // Cheapest per seat last; the first plan's price is the list price every\n  // discount is measured against.\n  plans: Plan[];\n  maxSeats?: number;\n  defaultSeats?: number;\n  yearlyDiscount?: number;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const [seats, setSeats] = useState(defaultSeats);\n  const [billing, setBilling] = useState<Billing>(\"monthly\");\n  const plan = planFor(plans, seats);\n  const list = plans[0];\n\n  // Why the plan is what it is, rewritten only when a threshold is crossed\n  // so it reads as the reason for the change that just happened.\n  const [note, setNote] = useState(() => initialNote(plans, plan));\n  const lastPlan = useRef(plan);\n  useEffect(() => {\n    const before = lastPlan.current;\n    if (before === plan) return;\n    lastPlan.current = plan;\n    const up = plan.minSeats > before.minSeats;\n    setNote(\n      up\n        ? `${plan.name} from ${plan.minSeats} seats: every seat is now ${money(plan.seatPrice)}.`\n        : `Under ${before.minSeats} seats, back to ${plan.name} at ${money(plan.seatPrice)}.`,\n    );\n  }, [plan]);\n\n  const gross = seats * list.seatPrice;\n  const volume = seats * (list.seatPrice - plan.seatPrice);\n  const monthly = gross - volume;\n  const yearlyCut = billing === \"yearly\" ? monthly * yearlyDiscount : 0;\n  const total = monthly - yearlyCut;\n  const hasVolume = plan !== list;\n  const yearly = billing === \"yearly\";\n\n  return (\n    <div\n      className={cn(\n        \"flex w-[min(440px,100%)] flex-col gap-5 rounded-[22px] bg-background p-5 shadow-raised\",\n        className,\n      )}\n    >\n      <div className=\"flex items-start justify-between gap-3\">\n        <div className=\"flex flex-col gap-1\">\n          <p className=\"text-[13px] font-medium text-muted-foreground\">Your team</p>\n          {/* The wheels are boxes, so there is no shared baseline: the\n              label sits on the bottom edge and 4px lifts it onto the\n              digits' baseline. */}\n          <p className=\"flex items-end gap-1.5 text-[28px] leading-none font-semibold tracking-tight tabular-nums\">\n            {/* Rolls like the prices below, so the cause and the effect\n                move the same way. */}\n            <RollingCount value={seats} reduceMotion={reduceMotion} />\n            <span className=\"mb-1 text-[15px] leading-none font-medium text-muted-foreground\">\n              {seats === 1 ? \"seat\" : \"seats\"}\n            </span>\n          </p>\n        </div>\n        <PlanBadge\n          plan={plan}\n          rank={plans.indexOf(plan)}\n          reduceMotion={reduceMotion}\n        />\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <SeatSlider\n          value={seats}\n          max={maxSeats}\n          thresholds={plans.slice(1)}\n          onChange={setSeats}\n          reduceMotion={reduceMotion}\n        />\n        {/* A fixed slot, so the invoice below never shifts: one line where\n            the notes fit on one, two on a phone. */}\n        <div className=\"relative h-10 sm:h-5\" aria-live=\"polite\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.p\n              key={note}\n              {...BLUR_IN}\n              transition={{ duration: reduceMotion ? 0 : 0.25, ease: EASE_OUT }}\n              className=\"absolute inset-x-0 top-0 text-[13px] leading-5 text-pretty text-muted-foreground\"\n            >\n              {note}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <BillingSwitch\n        value={billing}\n        onChange={setBilling}\n        discount={yearlyDiscount}\n      />\n\n      <dl className=\"flex flex-col text-[14px]\">\n        <Row\n          label={`${seats} × ${money(list.seatPrice)} list price`}\n          value={gross}\n          reduceMotion={reduceMotion}\n        />\n        <Row\n          label={\n            hasVolume\n              ? `${plan.name} rate, ${money(list.seatPrice - plan.seatPrice)} off a seat`\n              : `Volume rate from ${plans[1]?.minSeats ?? \"more\"} seats`\n          }\n          value={-volume}\n          dim={!hasVolume}\n          reduceMotion={reduceMotion}\n        />\n        <Row\n          label={\n            yearly\n              ? `Yearly billing, ${Math.round(yearlyDiscount * 100)}% off`\n              : `Pay yearly to save ${Math.round(yearlyDiscount * 100)}%`\n          }\n          value={-yearlyCut}\n          dim={!yearly}\n          reduceMotion={reduceMotion}\n        />\n        <div className=\"mt-2 flex items-end justify-between gap-3 border-t border-dashed border-border pt-4\">\n          <dt className=\"flex flex-col gap-1\">\n            <span className=\"text-[14px] font-medium\">Total</span>\n            <div className=\"relative h-5 text-[13px] text-muted-foreground\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.p\n                  key={billing}\n                  {...BLUR_IN}\n                  transition={{\n                    duration: reduceMotion ? 0 : 0.25,\n                    ease: EASE_OUT,\n                  }}\n                  className=\"absolute top-0 left-0 whitespace-nowrap tabular-nums\"\n                >\n                  {yearly\n                    ? `Billed ${money(total * 12, false)} a year`\n                    : \"Billed monthly\"}\n                </motion.p>\n              </AnimatePresence>\n            </div>\n          </dt>\n          <dd className=\"flex items-baseline gap-2\">\n            <Struck show={yearly} reduceMotion={reduceMotion}>\n              {money(monthly)}\n            </Struck>\n            <span className=\"text-[28px] leading-none font-semibold tracking-tight\">\n              <RollingMoney value={total} reduceMotion={reduceMotion} />\n            </span>\n            <span className=\"text-[14px] text-muted-foreground\">/mo</span>\n          </dd>\n        </div>\n      </dl>\n    </div>\n  );\n}\n\nfunction initialNote(plans: Plan[], plan: Plan) {\n  const next = plans[plans.indexOf(plan) + 1];\n  return next\n    ? `${next.name} pricing kicks in at ${next.minSeats} seats.`\n    : `${plan.name} pricing applies from ${plan.minSeats} seats.`;\n}\n\nfunction Row({\n  label,\n  value,\n  dim,\n  reduceMotion,\n}: {\n  label: string;\n  value: number;\n  dim?: boolean;\n  reduceMotion: boolean;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex h-9 items-center justify-between gap-3 transition-colors duration-200 ease-out\",\n        dim ? \"text-muted-foreground\" : \"text-foreground\",\n      )}\n    >\n      <dt className=\"relative min-w-0 flex-1 truncate\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={label}\n            {...BLUR_IN}\n            transition={{ duration: reduceMotion ? 0 : 0.22, ease: EASE_OUT }}\n            className=\"block truncate\"\n          >\n            {label}\n          </motion.span>\n        </AnimatePresence>\n      </dt>\n      <dd className=\"shrink-0\">\n        <RollingMoney value={value} reduceMotion={reduceMotion} />\n      </dd>\n    </div>\n  );\n}\n\n// The monthly price you'd have paid, crossed out by a line that draws across\n// it, left to right, the moment yearly billing is on.\nfunction Struck({\n  show,\n  reduceMotion,\n  children,\n}: {\n  show: boolean;\n  reduceMotion: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <AnimatePresence initial={false}>\n      {show && (\n        <motion.s\n          initial={{ opacity: 0, filter: \"blur(4px)\" }}\n          animate={{ opacity: 1, filter: \"blur(0px)\" }}\n          exit={{\n            opacity: 0,\n            filter: \"blur(2px)\",\n            transition: { duration: 0.15 },\n          }}\n          transition={{ duration: reduceMotion ? 0 : 0.2, ease: EASE_OUT }}\n          className=\"relative text-[15px] text-muted-foreground tabular-nums no-underline\"\n        >\n          <span className=\"sr-only\">was </span>\n          {children}\n          <motion.span\n            aria-hidden\n            className=\"absolute inset-x-[-2px] top-1/2 h-[1.5px] origin-left bg-muted-foreground\"\n            initial={{ scaleX: 0 }}\n            animate={{ scaleX: 1 }}\n            transition={{\n              duration: reduceMotion ? 0 : 0.3,\n              delay: reduceMotion ? 0 : 0.08,\n              ease: EASE_OUT,\n            }}\n          />\n        </motion.s>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// Every digit is its own wheel, keyed from the right so the cents stay the\n// cents as the number grows. Each wheel takes the short way round, so a 9\n// becoming a 0 rolls forward one notch instead of back through eight.\nfunction RollingMoney({\n  value,\n  reduceMotion,\n}: {\n  value: number;\n  reduceMotion: boolean;\n}) {\n  const negative = value < -0.004;\n  const text = money(Math.abs(value));\n  const chars = [...text];\n  return (\n    <span className=\"inline-flex tabular-nums\">\n      <span className=\"sr-only\">{negative ? `minus ${text}` : text}</span>\n      <span aria-hidden className=\"inline-flex items-center\">\n        {/* A real minus sign, kept as a static glyph that fades. */}\n        <span\n          className={cn(\n            \"inline-flex h-[1.2em] items-center overflow-hidden transition-[opacity,width] duration-200 ease-out\",\n            negative ? \"w-[0.6em] opacity-100\" : \"w-0 opacity-0\",\n          )}\n        >\n          −\n        </span>\n        {chars.map((char, i) => {\n          const fromRight = chars.length - 1 - i;\n          return /\\d/.test(char) ? (\n            <Digit\n              key={`d${fromRight}`}\n              digit={Number(char)}\n              reduceMotion={reduceMotion}\n            />\n          ) : (\n            <span\n              key={`c${fromRight}${char}`}\n              className=\"inline-flex h-[1.2em] items-center\"\n            >\n              {char}\n            </span>\n          );\n        })}\n      </span>\n    </span>\n  );\n}\n\nfunction RollingCount({\n  value,\n  reduceMotion,\n}: {\n  value: number;\n  reduceMotion: boolean;\n}) {\n  const chars = [...String(value)];\n  return (\n    <span className=\"inline-flex\">\n      <span className=\"sr-only\">{value}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {chars.map((char, i) => (\n          <Digit\n            key={chars.length - 1 - i}\n            digit={Number(char)}\n            reduceMotion={reduceMotion}\n          />\n        ))}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  reduceMotion,\n}: {\n  digit: number;\n  reduceMotion: boolean;\n}) {\n  const target = useRef(digit);\n  const position = useSpring(digit, { visualDuration: 0.35, bounce: 0.1 });\n\n  useEffect(() => {\n    const current = ((target.current % 10) + 10) % 10;\n    const delta = ((digit - current + 15) % 10) - 5;\n    target.current += delta;\n    if (reduceMotion) position.jump(target.current);\n    else position.set(target.current);\n  }, [digit, reduceMotion, position]);\n\n  return (\n    <motion.span\n      className=\"relative inline-block h-[1.2em] w-[0.62em] overflow-hidden [mask-image:linear-gradient(transparent,black_22%,black_78%,transparent)]\"\n      initial={{ opacity: 0, filter: \"blur(4px)\" }}\n      animate={{ opacity: 1, filter: \"blur(0px)\" }}\n      transition={{ duration: reduceMotion ? 0 : 0.2, ease: EASE_OUT }}\n    >\n      {Array.from({ length: 10 }, (_, g) => (\n        <Glyph key={g} glyph={g} position={position} />\n      ))}\n    </motion.span>\n  );\n}\n\nfunction Glyph({\n  glyph,\n  position,\n}: {\n  glyph: number;\n  position: MotionValue<number>;\n}) {\n  // Each glyph sits within five slots of the position, so ten nodes make an\n  // endless wheel; the jump from +5 to -5 happens out of sight.\n  const transform = useTransform(position, (p) => {\n    const offset = ((((glyph - p) % 10) + 15) % 10) - 5;\n    return `translateY(${offset * 100}%)`;\n  });\n  return (\n    <motion.span\n      className=\"absolute inset-0 flex items-center justify-center\"\n      style={{ transform }}\n    >\n      {glyph}\n    </motion.span>\n  );\n}\n\nfunction PlanBadge({\n  plan,\n  rank,\n  reduceMotion,\n}: {\n  plan: Plan;\n  rank: number;\n  reduceMotion: boolean;\n}) {\n  return (\n    <motion.span\n      layout={!reduceMotion}\n      transition={{ type: \"spring\", duration: 0.35, bounce: 0.15 }}\n      className={cn(\n        \"relative inline-flex h-8 items-center gap-1.5 overflow-hidden rounded-full px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-300 ease-out\",\n        rank === 0 && \"bg-muted text-foreground\",\n        rank === 1 &&\n          \"bg-background text-foreground shadow-[inset_0_0_0_1px_var(--foreground)]\",\n        rank >= 2 && \"bg-foreground text-background\",\n      )}\n    >\n      <motion.span\n        layout={reduceMotion ? false : \"position\"}\n        className=\"text-[12px] opacity-70\"\n      >\n        Plan\n      </motion.span>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={plan.name}\n          layout={reduceMotion ? false : \"position\"}\n          initial={{ opacity: 0, y: 8, filter: \"blur(4px)\" }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={{\n            opacity: 0,\n            y: -6,\n            filter: \"blur(2px)\",\n            transition: { duration: 0.15 },\n          }}\n          transition={{ duration: reduceMotion ? 0 : 0.25, ease: EASE_OUT }}\n        >\n          {plan.name}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nfunction SeatSlider({\n  value,\n  max,\n  thresholds,\n  onChange,\n  reduceMotion,\n}: {\n  value: number;\n  max: number;\n  thresholds: Plan[];\n  onChange: (seats: number) => void;\n  reduceMotion: boolean;\n}) {\n  const id = useId();\n  const at = (seats: number) => (seats - 1) / (max - 1);\n  // The fill glides behind the thumb, so a keyboard step or a click on the\n  // track travels instead of jumping.\n  const fill = useSpring(at(value), { visualDuration: 0.2, bounce: 0 });\n  useEffect(() => {\n    if (reduceMotion) fill.jump(at(value));\n    else fill.set(at(value));\n  });\n  const scaleX = fill;\n  // The thumb's centre travels inside the track minus its own width, the\n  // way a native range input places it.\n  const left = useTransform(fill, (f) => `calc(${f * 100}% - ${f * 20}px)`);\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <label htmlFor={id} className=\"sr-only\">\n        Seats\n      </label>\n      <div className=\"relative h-10\">\n        <div className=\"absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-muted shadow-wheel\">\n          <motion.div\n            className=\"h-full origin-left bg-foreground\"\n            style={{ scaleX }}\n          />\n        </div>\n        {thresholds.map((plan) => (\n          <span\n            key={plan.name}\n            aria-hidden\n            className={cn(\n              \"absolute top-1/2 h-3.5 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-200 ease-out\",\n              value >= plan.minSeats ? \"bg-background\" : \"bg-muted-foreground/60\",\n            )}\n            style={{\n              left: `calc(${at(plan.minSeats) * 100}% + ${10 - at(plan.minSeats) * 20}px)`,\n            }}\n          />\n        ))}\n        <motion.span\n          aria-hidden\n          className=\"pointer-events-none absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-background shadow-raised\"\n          style={{ left }}\n        />\n        <input\n          id={id}\n          type=\"range\"\n          min={1}\n          max={max}\n          step={1}\n          value={value}\n          onChange={(e) => onChange(Number(e.target.value))}\n          aria-valuetext={`${value} ${value === 1 ? \"seat\" : \"seats\"}`}\n          className=\"peer absolute inset-0 h-full w-full cursor-pointer touch-pan-y appearance-none rounded-full opacity-0\"\n        />\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute -inset-1 rounded-full outline-hidden peer-focus-visible:outline-2 peer-focus-visible:outline-solid focus-visible:outline-solid peer-focus-visible:outline-foreground\"\n        />\n      </div>\n      <div\n        aria-hidden\n        className=\"relative h-4 text-[12px] text-muted-foreground tabular-nums\"\n      >\n        <span className=\"absolute left-0\">1</span>\n        {thresholds.map((plan) => (\n          <span\n            key={plan.name}\n            className={cn(\n              \"absolute -translate-x-1/2 whitespace-nowrap transition-colors duration-200 ease-out\",\n              value >= plan.minSeats && \"text-foreground\",\n            )}\n            style={{\n              left: `calc(${at(plan.minSeats) * 100}% + ${10 - at(plan.minSeats) * 20}px)`,\n            }}\n          >\n            {plan.minSeats} · {plan.name}\n          </span>\n        ))}\n        <span className=\"absolute right-0\">{max}</span>\n      </div>\n    </div>\n  );\n}\n\nfunction BillingSwitch({\n  value,\n  onChange,\n  discount,\n}: {\n  value: Billing;\n  onChange: (b: Billing) => void;\n  discount: number;\n}) {\n  const options: { id: Billing; label: string }[] = [\n    { id: \"monthly\", label: \"Monthly\" },\n    { id: \"yearly\", label: \"Yearly\" },\n  ];\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label=\"Billing period\"\n      className=\"relative grid h-10 grid-cols-2 rounded-full bg-muted p-1 shadow-wheel\"\n      onKeyDown={(e) => {\n        if (\n          [\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\"].includes(e.key)\n        ) {\n          e.preventDefault();\n          const next = value === \"monthly\" ? \"yearly\" : \"monthly\";\n          onChange(next);\n          (\n            e.currentTarget.querySelector(\n              `[data-id=\"${next}\"]`,\n            ) as HTMLElement | null\n          )?.focus();\n        }\n      }}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-background shadow-raised transition-transform duration-250 ease-[cubic-bezier(0.77,0,0.175,1)] motion-reduce:transition-none\",\n          value === \"yearly\" && \"translate-x-full\",\n        )}\n      />\n      {options.map((option) => {\n        const active = option.id === value;\n        return (\n          <button\n            key={option.id}\n            type=\"button\"\n            role=\"radio\"\n            data-id={option.id}\n            aria-checked={active}\n            tabIndex={active ? 0 : -1}\n            onClick={() => onChange(option.id)}\n            className={cn(\n              \"relative flex touch-manipulation items-center justify-center gap-1.5 rounded-full text-[14px] font-medium outline-hidden transition-[color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n              active ? \"text-foreground\" : \"text-muted-foreground\",\n            )}\n          >\n            {option.label}\n            {option.id === \"yearly\" && (\n              <span className=\"text-[12px] font-normal text-muted-foreground\">\n                −{Math.round(discount * 100)}%\n              </span>\n            )}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nconst PLANS: Plan[] = [\n  { name: \"Starter\", minSeats: 1, seatPrice: 12 },\n  { name: \"Team\", minSeats: 10, seatPrice: 10 },\n  { name: \"Business\", minSeats: 25, seatPrice: 8 },\n];\n\nexport default function PricingCalculatorDemo() {\n  return <PricingCalculator plans={PLANS} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)","shadow-wheel":"inset 0 1px 2px oklch(0 0 0 / 0.08), inset 0 0 0 1px oklch(0 0 0 / 0.04)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)","shadow-wheel":"inset 0 1px 2px oklch(0 0 0 / 0.5), inset 0 0 0 1px oklch(1 0 0 / 0.04)"},"theme":{"shadow-raised":"var(--shadow-raised)","shadow-wheel":"var(--shadow-wheel)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/pricing-calculator"}