{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pricing-toggle","type":"registry:component","title":"Pricing toggle","description":"Monthly or yearly: the thumb slides with a little give, and every price on the page rolls to its new amount.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/pricing-toggle.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * PRICING TOGGLE: monthly or yearly, and every price rolls\n *\n *   toggle   the thumb slides and resizes to the chosen option\n *            with a little give; the labels trade colour\n *   badge    \"Save 20%\" sits inside Yearly and lights up in\n *            your brand colour when it's chosen\n *   prices   Price rolls each figure to its new amount, and the\n *            billing line crossfades in the direction you went\n *\n * PricingToggle is the control; Price is the number. Use them\n * together across your plan columns.\n * ───────────────────────────────────────────────────────── */\n\nexport interface BillingOption {\n  value: string;\n  label: string;\n  /** e.g. \"Save 20%\". */\n  badge?: string;\n}\n\nexport interface PricingToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  value: string;\n  onValueChange: (value: string) => void;\n  options?: BillingOption[];\n  label?: string;\n}\n\nexport interface PriceProps extends React.HTMLAttributes<HTMLDivElement> {\n  amount: number;\n  currency?: string;\n  locales?: string | string[];\n  /** e.g. \"/mo\"; hidden for free plans. */\n  period?: string;\n  /** A line under the price, e.g. \"$288 billed yearly\". */\n  note?: string;\n  /** Which way the toggle moved (1 or -1), so the note slides with it. */\n  direction?: number;\n  /** Classes for the figure, e.g. its size. */\n  numberClassName?: string;\n}\n\nconst BILLING: BillingOption[] = [\n  { value: \"monthly\", label: \"Monthly\" },\n  { value: \"yearly\", label: \"Yearly\", badge: \"Save 20%\" },\n];\n// A little give at the end of the slide, so the thumb feels like it was thrown, not placed.\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\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 PricingToggle({ value, onValueChange, options = BILLING, label = \"Billing period\", className = \"\", ...props }: PricingToggleProps) {\n  const reduced = useReducedMotion();\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [thumb, setThumb] = React.useState({ left: 0, width: 0, ready: false });\n  const active = Math.max(0, options.findIndex((o) => o.value === value));\n\n  React.useLayoutEffect(() => {\n    const measure = () => {\n      const el = refs.current[active];\n      if (!el) return;\n      const left = el.offsetLeft;\n      const width = el.offsetWidth;\n      setThumb((t) => (t.left === left && t.width === width ? t : { left, width, ready: t.width > 0 }));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    refs.current.forEach((el) => el && observer.observe(el));\n    return () => observer.disconnect();\n  }, [active, options]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n    e.preventDefault();\n    const next = (active + (e.key === \"ArrowRight\" ? 1 : -1) + options.length) % options.length;\n    onValueChange(options[next].value);\n    refs.current[next]?.focus();\n  };\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={label}\n      onKeyDown={onKeyDown}\n      className={`relative inline-flex items-center rounded-full border border-border bg-muted/60 p-1 ${className}`}\n      {...props}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute inset-y-1 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n        style={{\n          width: thumb.width,\n          transform: `translateX(${thumb.left}px)`,\n          transition: thumb.ready && !reduced ? `transform 520ms ${THROW}, width 420ms ${EASE}` : \"none\",\n        }}\n      />\n      {options.map((option, i) => {\n        const selected = i === active;\n        return (\n          <button\n            key={option.value}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            tabIndex={selected ? 0 : -1}\n            onClick={() => onValueChange(option.value)}\n            className={`relative flex h-8 items-center gap-2 rounded-full px-3.5 text-[13px] font-medium transition-colors duration-300 ${FOCUS} ${\n              selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"\n            }`}\n          >\n            {option.label}\n            {option.badge && (\n              <span\n                className={`rounded-full px-1.5 py-px text-[11px] font-medium transition-[background-color,color] duration-300 ${\n                  selected ? \"bg-primary text-primary-foreground\" : \"bg-primary/10 text-primary\"\n                }`}\n              >\n                {option.badge}\n              </span>\n            )}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport function Price({\n  amount,\n  currency = \"USD\",\n  locales,\n  period = \"/mo\",\n  note,\n  direction = 1,\n  numberClassName = \"text-[40px] font-medium leading-[1.15] tracking-[-0.03em] text-foreground\",\n  className = \"\",\n  ...props\n}: PriceProps) {\n  const format = React.useMemo<Intl.NumberFormatOptions>(\n    () => ({ style: \"currency\", currency, maximumFractionDigits: amount % 1 ? 2 : 0, minimumFractionDigits: amount % 1 ? 2 : 0 }),\n    [currency, amount]\n  );\n  return (\n    <div className={className} {...props}>\n      <div className=\"flex items-baseline gap-1\">\n        <NumberRoll value={amount} format={format} locales={locales} className={numberClassName} />\n        {amount > 0 && period && <span className=\"text-[14px] text-muted-foreground\">{period}</span>}\n      </div>\n      {note !== undefined && (\n        <div className=\"relative mt-1 h-5 overflow-hidden text-[12.5px] text-muted-foreground\">\n          <span\n            key={note}\n            className=\"absolute inset-0 truncate motion-safe:animate-[ui-note-in_380ms_cubic-bezier(0.23,1,0.32,1)_both]\"\n            style={{ \"--ui-from\": `${direction > 0 ? 100 : -100}%` } as React.CSSProperties}\n          >\n            {note}\n          </span>\n        </div>\n      )}\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-note-in":{"from":{"opacity":"0","transform":"translateY(var(--ui-from))"},"to":{"opacity":"1","transform":"none"}},"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/pricing-toggle"}