{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "price-toggle",
  "type": "registry:ui",
  "title": "Price Toggle",
  "description": "Monthly or yearly switch whose savings badge swells the moment yearly is chosen.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/price-toggle.tsx",
      "content": "\"use client\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type BillingPeriod = \"monthly\" | \"yearly\";\n\n/**\n * Whole-number percentage saved by paying yearly. `savingsPercent(20, 192)` → 20.\n * Use it to write the badge from real prices instead of hard-coding it.\n */\nexport function savingsPercent(monthlyPrice: number, yearlyPrice: number) {\n  if (monthlyPrice <= 0) return 0;\n  return Math.max(0, Math.round((1 - yearlyPrice / (monthlyPrice * 12)) * 100));\n}\n\nexport type PriceToggleProps = Omit<RadioGroup.Props<BillingPeriod>, \"className\" | \"value\" | \"defaultValue\" | \"onValueChange\"> & {\n  value?: BillingPeriod;\n  defaultValue?: BillingPeriod;\n  onValueChange?: (value: BillingPeriod) => void;\n  /** The badge on the yearly option, like \"Save 20%\" or \"2 months free\". Part of its accessible name. */\n  savings?: React.ReactNode;\n  monthlyLabel?: React.ReactNode;\n  yearlyLabel?: React.ReactNode;\n  size?: \"sm\" | \"md\";\n  className?: string;\n};\n\nexport function PriceToggle({\n  value: valueProp,\n  defaultValue = \"monthly\",\n  onValueChange,\n  savings,\n  monthlyLabel = \"Monthly\",\n  yearlyLabel = \"Yearly\",\n  size = \"md\",\n  disabled,\n  className,\n  onKeyDown,\n  onPointerDown,\n  ...rest\n}: PriceToggleProps) {\n  const [value, setValue] = useControllableState<BillingPeriod>({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const pill = useId();\n  // Arrow keys switch on the same frame; only a pointer press glides the pill.\n  const [keyboard, setKeyboard] = useState(false);\n  const instant = reduce || keyboard;\n\n  const options: { value: BillingPeriod; label: React.ReactNode }[] = [\n    { value: \"monthly\", label: monthlyLabel },\n    { value: \"yearly\", label: yearlyLabel },\n  ];\n\n  return (\n    <RadioGroup\n      aria-label=\"Billing period\"\n      value={value}\n      onValueChange={(next) => setValue(next as BillingPeriod)}\n      disabled={disabled}\n      data-size={size}\n      data-value={value}\n      onKeyDown={(e) => {\n        setKeyboard(true);\n        onKeyDown?.(e);\n      }}\n      onPointerDown={(e) => {\n        setKeyboard(false);\n        onPointerDown?.(e);\n      }}\n      className={cn(\n        \"relative inline-flex max-w-full items-stretch border border-line bg-page p-0.5\",\n        size === \"sm\" ? \"h-7 rounded-md\" : \"h-8 rounded-lg\",\n        disabled && \"opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      {options.map((o) => {\n        const active = value === o.value;\n        const yearly = o.value === \"yearly\";\n        return (\n          <Radio.Root\n            key={o.value}\n            value={o.value}\n            nativeButton\n            render={<button type=\"button\" />}\n            className={cn(\n              \"group/period relative flex min-w-0 select-none items-center justify-center outline-none\",\n              size === \"sm\" ? \"rounded-[5px] px-2 text-[12px]\" : \"rounded-md px-2.5 text-[12.5px]\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n              \"transition-colors duration-150\",\n              // Touch: the 28–32px segment gets a 44px tall hit area without changing the drawing.\n              \"before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden\",\n              active ? \"text-fg\" : \"text-fg-3 hover:text-fg-2\",\n              \"data-disabled:cursor-not-allowed data-disabled:hover:text-fg-3\",\n            )}\n          >\n            {active && (\n              <motion.span\n                layoutId={pill}\n                aria-hidden\n                className={cn(\n                  \"absolute inset-0 border border-line-2 bg-raised shadow-[var(--shadow)]\",\n                  size === \"sm\" ? \"rounded-[5px]\" : \"rounded-md\",\n                )}\n                transition={instant ? { duration: 0 } : spring.snappy}\n              />\n            )}\n            {/* The label presses; the pill stays put so the press never reads as a jump. */}\n            <span\n              className={cn(\n                \"relative flex min-w-0 items-center font-medium tracking-[-0.005em]\",\n                \"transition-[scale] duration-100 ease-out motion-safe:group-active/period:scale-[0.96] group-data-disabled/period:scale-100\",\n                size === \"sm\" ? \"gap-1.5\" : \"gap-2\",\n              )}\n            >\n              <span className=\"truncate\">{o.label}</span>\n              {yearly && savings != null && <SavingsBadge on={active} size={size} reduce={!!reduce}>{savings}</SavingsBadge>}\n            </span>\n          </Radio.Root>\n        );\n      })}\n    </RadioGroup>\n  );\n}\n\nfunction SavingsBadge({ on, size, reduce, children }: { on: boolean; size: \"sm\" | \"md\"; reduce: boolean; children: React.ReactNode }) {\n  return (\n    <motion.span\n      data-state={on ? \"on\" : \"off\"}\n      className={cn(\n        \"inline-flex shrink-0 items-center whitespace-nowrap rounded-full border font-medium tabular tracking-normal\",\n        size === \"sm\" ? \"h-4 px-1.5 text-[10px]\" : \"h-[18px] px-1.5 text-[10.5px]\",\n        \"transition-[background-color,border-color,color] duration-200 ease-out\",\n        on ? \"border-transparent bg-success-soft text-success\" : \"border-line-2 text-fg-3\",\n      )}\n      // Choosing yearly is the moment the saving becomes real: the badge swells once and settles.\n      initial={false}\n      animate={on && !reduce ? { scale: [1, 1.14, 1] } : { scale: 1 }}\n      transition={{ duration: 0.34, ease: ease.out, times: [0, 0.4, 1] }}\n    >\n      {children}\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/price-toggle"
}
