{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pricing-toggle","type":"registry:component","title":"Pricing toggle","description":"Each digit rolls to its new price on its own, and new digits grow in.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/pricing-toggle.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useId, useRef, useState } from \"react\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Billing = \"monthly\" | \"yearly\";\n\nexport type Plan = {\n  name: string;\n  blurb: string;\n  monthly: number;\n  yearly: number;\n  features: string[];\n  featured?: boolean;\n};\n\nconst EASE = \"ease-[cubic-bezier(0.23,1,0.32,1)]\";\nconst THUMB = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Longer than a usual UI move because a digit can travel up to nine places;\n// any faster and the roll reads as a flicker rather than counting.\nconst ROLL = { type: \"spring\", duration: 0.5, bounce: 0 } as const;\nconst OPTIONS: { value: Billing; label: string }[] = [\n  { value: \"monthly\", label: \"Monthly\" },\n  { value: \"yearly\", label: \"Yearly\" },\n];\n\nexport function BillingToggle({\n  value,\n  onChange,\n  saving,\n}: {\n  value: Billing;\n  onChange: (value: Billing) => void;\n  saving: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const group = useId();\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  const yearly = value === \"yearly\";\n\n  return (\n    <div className=\"relative\">\n      <LayoutGroup id={group}>\n        {/* 4px of padding around 36px pills keeps the radii concentric: 22 = 18 + 4. */}\n        <div role=\"radiogroup\" aria-label=\"Billing period\" className=\"flex h-11 rounded-full bg-muted p-1\">\n          {OPTIONS.map((option, i) => {\n            const checked = option.value === value;\n            return (\n              <button\n                key={option.value}\n                ref={(el) => {\n                  buttons.current[i] = el;\n                }}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={checked}\n                tabIndex={checked ? 0 : -1}\n                onClick={() => onChange(option.value)}\n                onKeyDown={(e) => {\n                  if (![\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\"].includes(e.key)) return;\n                  e.preventDefault();\n                  const next = (i + 1) % OPTIONS.length;\n                  onChange(OPTIONS[next].value);\n                  buttons.current[next]?.focus();\n                }}\n                className={cn(\n                  \"relative h-9 touch-manipulation rounded-full px-4 text-sm font-medium outline-hidden transition-[scale,color] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color]\",\n                  checked ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                {checked && (\n                  <motion.span\n                    layoutId=\"billing-thumb\"\n                    transition={reduceMotion ? { duration: 0 } : THUMB}\n                    className=\"absolute inset-0 rounded-full bg-background shadow-raised\"\n                  />\n                )}\n                <span className=\"relative\">{option.label}</span>\n                {option.value === \"yearly\" && <span className=\"sr-only\">, {saving}</span>}\n              </button>\n            );\n          })}\n        </div>\n      </LayoutGroup>\n      {/* Hangs off the right edge out of flow, so it never nudges the toggle.\n          Grows from its left edge, the side touching the option it describes. */}\n      <motion.span\n        aria-hidden\n        initial={false}\n        animate={\n          yearly\n            ? { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n            : reduceMotion\n              ? { opacity: 0 }\n              : { opacity: 0, scale: 0.9, filter: \"blur(4px)\" }\n        }\n        // Leaving is quicker and plain: a tag you just turned off shouldn't linger.\n        transition={yearly ? THUMB : { duration: 0.15, ease: [0.23, 1, 0.32, 1] }}\n        className=\"absolute top-1/2 left-full ml-2 flex h-7 origin-left -translate-y-1/2 items-center rounded-full border border-border px-2.5 text-[13px] font-medium whitespace-nowrap text-foreground\"\n      >\n        {saving}\n      </motion.span>\n    </div>\n  );\n}\n\n// Rolls each digit to its new value in its own column, like an odometer.\n// Columns are keyed by place value, so \"9\" to \"86\" keeps the ones column and\n// grows a tens column beside it.\nexport function RollingNumber({ value, className }: { value: number; className?: string }) {\n  const digits = String(Math.round(Math.abs(value)));\n  // Holds every column this number has ever needed, so a column that is no\n  // longer used can still shrink away instead of vanishing.\n  const [slots, setSlots] = useState(digits.length);\n  if (digits.length > slots) setSlots(digits.length);\n  // Columns present on first render just appear; only later ones grow in.\n  const [initialSlots] = useState(digits.length);\n\n  return (\n    <span aria-hidden className={cn(\"inline-flex tabular-nums\", className)}>\n      {Array.from({ length: slots }, (_, i) => {\n        const place = slots - 1 - i;\n        const index = digits.length - 1 - place;\n        return (\n          <DigitColumn\n            key={place}\n            digit={index >= 0 ? Number(digits[index]) : null}\n            grow={place >= initialSlots}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\nfunction DigitColumn({ digit, grow }: { digit: number | null; grow: boolean }) {\n  const reduceMotion = useReducedMotion();\n  // A column on its way out keeps showing its last digit while it shrinks.\n  const [shown, setShown] = useState(digit ?? 0);\n  if (digit !== null && digit !== shown) setShown(digit);\n  const present = digit !== null;\n\n  return (\n    <span\n      className={cn(\n        // 1ch is exactly one tabular digit wide, so a present column never\n        // needs measuring. Width only moves when a digit is added or removed.\n        \"relative inline-block h-[1.2em] overflow-hidden transition-[width,opacity,filter] duration-400 motion-reduce:transition-[opacity]\",\n        EASE,\n        present ? \"w-[1ch] opacity-100 blur-[0px]\" : \"w-0 opacity-0 blur-[2px]\",\n        // A column added after mount starts collapsed and widens, pushing the\n        // suffix over smoothly rather than jumping it.\n        grow && \"starting:w-0 starting:opacity-0 starting:blur-[2px]\",\n        // Softens the edges so digits roll in and out rather than being cut.\n        \"[mask-image:linear-gradient(transparent,black_18%,black_82%,transparent)]\",\n      )}\n    >\n      <motion.span\n        className=\"absolute inset-x-0 top-0 flex flex-col\"\n        initial={false}\n        animate={{ y: `${-shown * 10}%` }}\n        transition={reduceMotion ? { duration: 0 } : ROLL}\n      >\n        {Array.from({ length: 10 }, (_, n) => (\n          <span key={n} className=\"block h-[1.2em] text-center leading-[1.2em]\">\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n\nexport function PricingTable({\n  plans,\n  saving = \"Save 20%\",\n  defaultBilling = \"monthly\",\n  className,\n}: {\n  plans: Plan[];\n  saving?: string;\n  defaultBilling?: Billing;\n  className?: string;\n}) {\n  const [billing, setBilling] = useState<Billing>(defaultBilling);\n  const yearly = billing === \"yearly\";\n\n  return (\n    <div className={cn(\"flex w-[560px] max-w-full flex-col items-center gap-6\", className)}>\n      <BillingToggle value={billing} onChange={setBilling} saving={saving} />\n      <div className=\"grid w-full grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3\">\n        {plans.map((plan) => {\n          const price = yearly ? plan.yearly : plan.monthly;\n          return (\n            <div\n              key={plan.name}\n              className={cn(\n                \"flex flex-col rounded-2xl p-4\",\n                plan.featured ? \"bg-background shadow-raised\" : \"border border-border\",\n              )}\n            >\n              <div className=\"flex items-center justify-between gap-2\">\n                <h3 className=\"text-[15px] font-medium text-foreground\">{plan.name}</h3>\n                {plan.featured && <span className=\"text-xs text-muted-foreground\">Popular</span>}\n              </div>\n              <p className=\"mt-1 text-sm text-pretty text-muted-foreground\">{plan.blurb}</p>\n\n              {/* Bottom-aligned rather than baseline: the clipped digit columns have\n                  no text baseline of their own. */}\n              <p className=\"mt-4 flex items-end text-foreground\">\n                <span className=\"sr-only\">\n                  ${price} per {yearly ? \"year\" : \"month\"}\n                </span>\n                <span aria-hidden className=\"text-[32px] leading-[1.2] font-semibold\">\n                  $\n                </span>\n                <RollingNumber value={price} className=\"text-[32px] font-semibold\" />\n                {/* Both suffixes share one grid cell, so swapping them never\n                    moves the price. */}\n                {/* 5px lifts the small suffix onto the digits' baseline. */}\n                <span aria-hidden className=\"mb-[5px] ml-1 grid text-sm text-muted-foreground\">\n                  {([\"/mo\", \"/yr\"] as const).map((suffix) => {\n                    const on = (suffix === \"/yr\") === yearly;\n                    return (\n                      <span\n                        key={suffix}\n                        className={cn(\n                          \"col-start-1 row-start-1 transition-[opacity,filter] ease-out\",\n                          on ? \"opacity-100 blur-[0px] duration-200\" : \"opacity-0 blur-[4px] duration-150\",\n                        )}\n                      >\n                        {suffix}\n                      </span>\n                    );\n                  })}\n                </span>\n              </p>\n\n              <ul className=\"mt-4 flex flex-1 flex-col gap-2 text-sm text-muted-foreground\">\n                {plan.features.map((feature) => (\n                  <li key={feature} className=\"flex items-start gap-2\">\n                    <svg\n                      viewBox=\"0 0 16 16\"\n                      aria-hidden\n                      className=\"mt-0.5 size-4 shrink-0 text-foreground\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth={1.5}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    >\n                      <path d=\"m3.5 8.5 3 3 6-7\" />\n                    </svg>\n                    {feature}\n                  </li>\n                ))}\n              </ul>\n\n              <button\n                type=\"button\"\n                className={cn(\n                  \"mt-5 h-10 touch-manipulation rounded-lg text-sm font-medium outline-hidden transition-[scale,background-color] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[background-color]\",\n                  plan.featured\n                    ? \"bg-foreground text-background hover:bg-foreground/90\"\n                    : \"bg-muted text-foreground hover:bg-foreground/[0.08]\",\n                )}\n              >\n                Choose {plan.name}\n              </button>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nconst PLANS: Plan[] = [\n  {\n    name: \"Starter\",\n    blurb: \"For side projects.\",\n    monthly: 9,\n    yearly: 86,\n    features: [\"3 projects\", \"Basic analytics\", \"Email support\"],\n  },\n  {\n    name: \"Pro\",\n    blurb: \"For growing products.\",\n    monthly: 24,\n    yearly: 230,\n    features: [\"Unlimited projects\", \"Full analytics\", \"Priority support\"],\n    featured: true,\n  },\n  {\n    name: \"Team\",\n    blurb: \"For whole companies.\",\n    monthly: 59,\n    yearly: 566,\n    features: [\"Everything in Pro\", \"Shared workspaces\", \"SSO and audit log\"],\n  },\n];\n\nexport default function PricingToggleDemo() {\n  return <PricingTable 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)"},"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)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/pricing-toggle"}