{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pricing-toggle",
  "type": "registry:ui",
  "title": "Pricing Toggle",
  "description": "Pricing cards with a monthly / yearly switch, digit-rolling prices, savings badges and Intl currency formatting.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/pricing-toggle.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport type BillingPeriod = \"monthly\" | \"yearly\";\n\nexport interface PricingPlan {\n  id: string;\n  name: string;\n  /** One line under the plan name. */\n  description?: string;\n  /** Price per month when billed monthly. */\n  monthly: number;\n  /** Price per year when billed yearly. */\n  yearly: number;\n  features: string[];\n  /** Call-to-action label. */\n  cta: string;\n  /** Turns the call to action into a link. */\n  href?: string;\n  /** Emphasise this plan (accent border, filled button, badge). */\n  highlighted?: boolean;\n  /** Badge text for a highlighted plan. Defaults to \"Popular\". */\n  badge?: string;\n}\n\nexport interface PricingToggleProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  plans: PricingPlan[];\n  /** Selected billing period (controlled). */\n  billing?: BillingPeriod;\n  /** Initial billing period (uncontrolled). */\n  defaultBilling?: BillingPeriod;\n  onBillingChange?: (billing: BillingPeriod) => void;\n  /** ISO 4217 currency code. */\n  currency?: string;\n  /** BCP 47 locale used by Intl.NumberFormat. */\n  locale?: string;\n  /** With yearly billing, show the monthly equivalent or the yearly total as the big number. */\n  yearlyDisplay?: \"perMonth\" | \"perYear\";\n  /** How long each digit takes to roll, in ms. */\n  rollDuration?: number;\n  /** Show “Save X%” badges when yearly billing is selected. */\n  showSavings?: boolean;\n  /** Called when a plan's call to action is pressed (buttons only, not links). */\n  onSelectPlan?: (plan: PricingPlan, billing: BillingPeriod) => void;\n  /** Labels for the billing switch. */\n  monthlyLabel?: string;\n  yearlyLabel?: string;\n  /** Highlight, toggle thumb and focus colour. */\n  accent?: string;\n}\n\nconst DIGITS = [\"0\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\"];\nconst ROW = 1.15; // em — line box of one digit\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** One digit wheel. A CSS transition rolls it from whatever it showed before — never from zero. */\nfunction DigitWheel({ digit, duration }: { digit: number; duration: number }) {\n  return (\n    <span className=\"relative inline-block overflow-hidden\" style={{ height: `${ROW}em`, lineHeight: ROW }}>\n      <span className=\"invisible\">0</span>\n      <span\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center transition-transform ease-[cubic-bezier(0.22,1,0.36,1)]\"\n        style={{ transform: `translateY(${-digit * ROW}em)`, transitionDuration: `${duration}ms` }}\n      >\n        {DIGITS.map((d) => (\n          <span key={d} style={{ height: `${ROW}em` }}>\n            {d}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\n/**\n * Splits a formatted number into keyed tokens. Integer digits are keyed by their position from the\n * right, so “29” → “290” keeps the 9 and 2 wheels (they roll) and only adds a new one.\n */\nfunction tokens(formatter: Intl.NumberFormat, value: number) {\n  const parts = formatter.formatToParts(value);\n  const integerDigits = parts.filter((p) => p.type === \"integer\").reduce((n, p) => n + p.value.length, 0);\n  let seenInteger = 0;\n  let fraction = 0;\n  const counts: Record<string, number> = {};\n  const out: { key: string; char: string; digit: boolean }[] = [];\n  for (const part of parts) {\n    if (part.type === \"integer\" || part.type === \"fraction\") {\n      for (const char of part.value) {\n        const isDigit = char >= \"0\" && char <= \"9\";\n        const key = part.type === \"integer\" ? `i${integerDigits - seenInteger++}` : `f${fraction++}`;\n        out.push({ key, char, digit: isDigit });\n      }\n    } else {\n      // Separators are keyed by the digit position that follows them; other literals by order of appearance.\n      const n = (counts[part.type] = (counts[part.type] ?? 0) + 1);\n      const key = part.type === \"group\" ? `g${integerDigits - seenInteger}` : `${part.type}${n}`;\n      out.push({ key, char: part.value, digit: false });\n    }\n  }\n  return out;\n}\n\nfunction RollingPrice({ value, formatter, duration }: { value: number; formatter: Intl.NumberFormat; duration: number }) {\n  return (\n    <span aria-hidden=\"true\" className=\"inline-flex items-baseline whitespace-pre tabular-nums\">\n      {tokens(formatter, value).map((t) =>\n        t.digit ? <DigitWheel key={t.key} digit={Number(t.char)} duration={duration} /> : <span key={t.key}>{t.char}</span>,\n      )}\n    </span>\n  );\n}\n\nfunction CheckIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" className=\"mt-[3px] size-3.5 shrink-0\">\n      <path d=\"M3.5 8.5l3 3 6-7\" />\n    </svg>\n  );\n}\n\n/**\n * Pricing cards with a monthly / yearly switch. Prices roll digit by digit from their previous value,\n * per-plan savings badges spring in for yearly billing, and a polite live region announces the new prices.\n */\nexport function PricingToggle({\n  plans,\n  billing: billingProp,\n  defaultBilling = \"monthly\",\n  onBillingChange,\n  currency = \"USD\",\n  locale = \"en-US\",\n  yearlyDisplay = \"perMonth\",\n  rollDuration = 600,\n  showSavings = true,\n  onSelectPlan,\n  monthlyLabel = \"Monthly\",\n  yearlyLabel = \"Yearly\",\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: PricingToggleProps) {\n  const id = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const [internal, setInternal] = React.useState<BillingPeriod>(defaultBilling);\n  const billing = billingProp ?? internal;\n  const yearly = billing === \"yearly\";\n  const duration = reduceMotion ? 0 : rollDuration;\n  const radioRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const select = (next: BillingPeriod) => {\n    if (next === billing) return;\n    if (billingProp === undefined) setInternal(next);\n    onBillingChange?.(next);\n  };\n\n  /** Formatter per amount: whole numbers without decimals, others with the currency's usual digits. */\n  const format = React.useMemo(() => {\n    const make = (digits?: number) => {\n      try {\n        return new Intl.NumberFormat(locale, {\n          style: \"currency\",\n          currency,\n          ...(digits === undefined ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits }),\n        });\n      } catch {\n        return new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" });\n      }\n    };\n    const base = make();\n    const whole = make(0);\n    return (amount: number) => (Number.isInteger(amount) ? whole : base);\n  }, [currency, locale]);\n\n  const amountFor = (plan: PricingPlan) => {\n    if (!yearly) return plan.monthly;\n    return yearlyDisplay === \"perMonth\" ? Math.round((plan.yearly / 12) * 100) / 100 : plan.yearly;\n  };\n  const periodFor = () => (!yearly || yearlyDisplay === \"perMonth\" ? \"/mo\" : \"/yr\");\n  const savingFor = (plan: PricingPlan) =>\n    plan.monthly > 0 ? Math.round((1 - plan.yearly / (plan.monthly * 12)) * 100) : 0;\n  const bestSaving = Math.max(0, ...plans.map(savingFor));\n\n  const spoken = (plan: PricingPlan) => {\n    const amount = amountFor(plan);\n    const text = format(amount).format(amount);\n    if (!yearly) return `${text} per month`;\n    const saving = showSavings && savingFor(plan) > 0 ? `, saving ${savingFor(plan)}%` : \"\";\n    return yearlyDisplay === \"perMonth\"\n      ? `${text} per month, billed yearly at ${format(plan.yearly).format(plan.yearly)}${saving}`\n      : `${text} per year${saving}`;\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (![\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\", \"Home\", \"End\"].includes(event.key)) return;\n    event.preventDefault();\n    const next: BillingPeriod =\n      event.key === \"Home\" ? \"monthly\" : event.key === \"End\" ? \"yearly\" : billing === \"monthly\" ? \"yearly\" : \"monthly\";\n    radioRefs.current[next === \"monthly\" ? 0 : 1]?.focus();\n    select(next);\n  };\n\n  const focusRing =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pt-accent)]\";\n  const springy = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 32, mass: 0.8 };\n\n  return (\n    <div className={`flex w-full flex-col items-center gap-8 ${className}`} style={{ [\"--pt-accent\" as string]: accent, ...style }} {...props}>\n      {/* Billing period: a two-option radio group with a sliding thumb. */}\n      <div\n        role=\"radiogroup\"\n        aria-label=\"Billing period\"\n        onKeyDown={handleKeyDown}\n        className=\"relative inline-flex rounded-full border border-zinc-900/10 bg-zinc-900/[0.04] p-1 dark:border-white/10 dark:bg-white/[0.05]\"\n      >\n        {([\"monthly\", \"yearly\"] as const).map((period, i) => {\n          const checked = billing === period;\n          return (\n            <button\n              key={period}\n              ref={(el) => {\n                radioRefs.current[i] = el;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={checked}\n              tabIndex={checked ? 0 : -1}\n              onClick={() => select(period)}\n              className={`relative inline-flex h-9 items-center gap-2 rounded-full px-4 text-sm font-medium tracking-tight transition-colors duration-200 ${focusRing} ${\n                checked ? \"text-zinc-950 dark:text-white\" : \"text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100\"\n              }`}\n            >\n              {checked && (\n                <motion.span\n                  aria-hidden=\"true\"\n                  layoutId={`${id}-billing`}\n                  transition={springy}\n                  className=\"absolute inset-0 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.05)] dark:bg-zinc-700 dark:shadow-[0_1px_2px_rgb(0_0_0/0.5),0_0_0_1px_rgb(255_255_255/0.07)]\"\n                />\n              )}\n              <span className=\"relative\">{period === \"monthly\" ? monthlyLabel : yearlyLabel}</span>\n              {period === \"yearly\" && showSavings && bestSaving > 0 && (\n                <span className=\"relative rounded-full bg-[var(--pt-accent)] px-1.5 py-0.5 font-mono text-[10px] font-semibold leading-none text-zinc-950\">\n                  −{bestSaving}%\n                </span>\n              )}\n            </button>\n          );\n        })}\n      </div>\n\n      <ul className=\"m-0 grid w-full list-none grid-cols-[repeat(auto-fit,minmax(min(100%,15rem),1fr))] gap-4 p-0\">\n        {plans.map((plan) => {\n          const amount = amountFor(plan);\n          const saving = savingFor(plan);\n          const highlighted = plan.highlighted;\n          const ctaClass = `mt-6 inline-flex h-10 w-full items-center justify-center rounded-lg text-sm font-medium tracking-tight transition-colors duration-150 ${focusRing} ${\n            highlighted\n              ? \"bg-[var(--pt-accent)] text-zinc-950 hover:brightness-95\"\n              : \"border border-zinc-900/15 bg-white text-zinc-900 hover:bg-zinc-50 dark:border-white/15 dark:bg-white/[0.04] dark:text-zinc-100 dark:hover:bg-white/[0.08]\"\n          }`;\n          return (\n            <li key={plan.id} className=\"min-w-0\">\n              <article\n                aria-labelledby={`${id}-${plan.id}`}\n                className={`relative flex h-full flex-col rounded-2xl border p-6 ${\n                  highlighted\n                    ? \"border-[var(--pt-accent)] bg-white shadow-[0_0_0_1px_var(--pt-accent),0_16px_40px_-24px_rgb(0_0_0/0.35)] dark:bg-zinc-900\"\n                    : \"border-zinc-900/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.03]\"\n                }`}\n              >\n                <div className=\"flex items-center justify-between gap-3\">\n                  <h3 id={`${id}-${plan.id}`} className=\"text-base font-semibold tracking-tight text-zinc-950 dark:text-zinc-50\">\n                    {plan.name}\n                  </h3>\n                  {highlighted && (\n                    <span className=\"rounded-full border border-[var(--pt-accent)] px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-900 dark:text-zinc-100\">\n                      {plan.badge ?? \"Popular\"}\n                    </span>\n                  )}\n                </div>\n                {plan.description && <p className=\"mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400\">{plan.description}</p>}\n\n                <div className=\"mt-5 flex flex-wrap items-baseline gap-x-1.5 gap-y-2\">\n                  <span className=\"text-4xl font-semibold tracking-tight text-zinc-950 dark:text-white\">\n                    <RollingPrice value={amount} formatter={format(amount)} duration={duration} />\n                  </span>\n                  <span aria-hidden=\"true\" className=\"font-mono text-xs text-zinc-500 dark:text-zinc-400\">\n                    {periodFor()}\n                  </span>\n                  <span className=\"sr-only\">{spoken(plan)}</span>\n                </div>\n                <div aria-hidden=\"true\" className=\"mt-1.5 flex min-h-5 flex-wrap items-center gap-2\">\n                  <p className=\"font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n                    {yearly ? (yearlyDisplay === \"perMonth\" ? `Billed ${format(plan.yearly).format(plan.yearly)} yearly` : \"Billed yearly\") : \"Billed monthly\"}\n                  </p>\n                  <AnimatePresence initial={false}>\n                    {yearly && showSavings && saving > 0 && (\n                      <motion.span\n                        key=\"save\"\n                        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, y: 4 }}\n                        animate={{ opacity: 1, scale: 1, y: 0 }}\n                        exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, y: 4 }}\n                        transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 24 }}\n                        className=\"rounded-full bg-[var(--pt-accent)]/15 px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.1em] text-zinc-900 ring-1 ring-[var(--pt-accent)]/40 ring-inset dark:text-zinc-100\"\n                      >\n                        Save {saving}%\n                      </motion.span>\n                    )}\n                  </AnimatePresence>\n                </div>\n\n                <ul className=\"mt-5 flex list-none flex-col gap-2.5 border-t border-zinc-900/10 p-0 pt-5 dark:border-white/10\">\n                  {plan.features.map((feature) => (\n                    <li key={feature} className=\"flex gap-2.5 text-sm text-zinc-700 dark:text-zinc-300\">\n                      <span className={highlighted ? \"text-[var(--pt-accent)]\" : \"text-zinc-400 dark:text-zinc-500\"}>\n                        <CheckIcon />\n                      </span>\n                      {feature}\n                    </li>\n                  ))}\n                </ul>\n\n                <div className=\"mt-auto\">\n                  {plan.href ? (\n                    <a href={plan.href} className={ctaClass}>\n                      {plan.cta}\n                    </a>\n                  ) : (\n                    <button type=\"button\" onClick={() => onSelectPlan?.(plan, billing)} className={ctaClass}>\n                      {plan.cta}\n                    </button>\n                  )}\n                </div>\n              </article>\n            </li>\n          );\n        })}\n      </ul>\n\n      {/* Announces the new prices after the billing period changes (initial content is not read out). */}\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {`${yearly ? yearlyLabel : monthlyLabel} billing. ${plans.map((plan) => `${plan.name}: ${spoken(plan)}.`).join(\" \")}`}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/pricing-toggle"
}
