{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "billing-toggle",
  "type": "registry:ui",
  "title": "Billing toggle",
  "description": "A monthly and yearly switch with a savings badge and prices that roll to the new amount.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/animated-counter.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/billing-toggle/billing-toggle.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState, type KeyboardEvent } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Transition, type Variants } from \"motion/react\";\nimport { AnimatedCounter } from \"../animated-counter/animated-counter\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\nexport interface BillingToggleOption {\n  value: string;\n  label: string;\n  /** Short savings note, e.g. \"Save 20%\". */\n  badge?: string;\n  /** Badge text once this option is selected, e.g. \"You save $48\". Defaults to `badge`. */\n  activeBadge?: string;\n}\n\nexport interface BillingToggleProps {\n  value: string;\n  onValueChange: (value: string) => void;\n  options?: BillingToggleOption[];\n  /** Accessible name of the group. */\n  label?: string;\n  size?: \"md\" | \"lg\";\n  className?: string;\n}\n\nconst { duration, ease, blur } = motionTokens;\n\n/** Critically damped: the thumb glides and lands without overshoot. */\nconst glide: Transition = { type: \"spring\", visualDuration: 0.34, bounce: 0 };\n\nconst DEFAULT_OPTIONS: BillingToggleOption[] = [\n  { value: \"monthly\", label: \"Monthly\" },\n  { value: \"yearly\", label: \"Yearly\", badge: \"Save 20%\" },\n];\n\nconst swap: Variants = {\n  hidden: { opacity: 0, y: \"0.45em\", filter: `blur(${blur.subtle}px)` },\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: duration.standard, ease: [...ease.enter] } },\n  gone: {\n    opacity: 0,\n    y: \"-0.45em\",\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: duration.fast, ease: [...ease.standard] },\n  },\n};\nconst still: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, transition: { duration: duration.fast } },\n  gone: { opacity: 0, transition: { duration: 0 } },\n};\n\n/**\n * Text that swaps in place. Every candidate sits in the same grid cell, so the box always has the\n * width of the longest one and nothing around it moves when the text changes.\n */\nfunction StableSwap({\n  text,\n  candidates,\n  reduced,\n  className,\n}: {\n  text: string;\n  candidates: string[];\n  reduced: boolean;\n  className?: string;\n}) {\n  return (\n    <span\n      className={cn(\"relative inline-grid justify-items-center overflow-y-clip [overflow-clip-margin:2px]\", className)}\n    >\n      {[...new Set(candidates)].map((candidate) => (\n        <span key={candidate} className=\"invisible [grid-area:1/1]\" aria-hidden=\"true\">\n          {candidate}\n        </span>\n      ))}\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={text}\n          className=\"inline-block [grid-area:1/1]\"\n          variants={reduced ? still : swap}\n          initial=\"hidden\"\n          animate=\"shown\"\n          exit=\"gone\"\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\ntype Thumb = { x: number; width: number };\n\n/** One thumb for the whole track; its position and width are measured from the selected option. */\nconst THUMB =\n  \"pointer-events-none absolute -z-1 rounded-button bg-surface shadow-[0_0_0_1px_var(--line-soft),0_1px_2px_oklch(0%_0_0/.06),0_2px_6px_oklch(0%_0_0/.04)] dark:shadow-[0_0_0_1px_var(--line),inset_0_1px_0_oklch(100%_0_0/.05),0_1px_2px_oklch(0%_0_0/.3)]\";\n\n/**\n * A billing period switch. One thumb glides between the options on a critically damped spring, and\n * the savings note on the cheaper period tints and rewrites itself in place once it is chosen.\n */\nexport function BillingToggle({\n  value,\n  onValueChange,\n  options = DEFAULT_OPTIONS,\n  label = \"Billing period\",\n  size = \"md\",\n  className,\n}: BillingToggleProps) {\n  const reduced = !!useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const refs = useRef<Array<HTMLButtonElement | null>>([]);\n  const [thumb, setThumb] = useState<Thumb | null>(null);\n  const selectedIndex = Math.max(\n    0,\n    options.findIndex((option) => option.value === value),\n  );\n\n  const measure = useCallback(() => {\n    const node = refs.current[selectedIndex];\n    if (!node) return;\n    setThumb((current) =>\n      current && current.x === node.offsetLeft && current.width === node.offsetWidth\n        ? current\n        : { x: node.offsetLeft, width: node.offsetWidth },\n    );\n  }, [selectedIndex]);\n\n  useLayoutEffect(() => {\n    measure();\n    const root = rootRef.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [measure]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    const step =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? 1\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? -1\n          : 0;\n    const target =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? options.length - 1\n          : step\n            ? (index + step + options.length) % options.length\n            : -1;\n    if (target < 0) return;\n    event.preventDefault();\n    onValueChange(options[target]!.value);\n    refs.current[target]?.focus();\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"radiogroup\"\n      aria-label={label}\n      className={cn(\n        // Override --billing-accent on any ancestor to recolor the savings note.\n        size === \"lg\" ? \"[--bt-h:42px] [--bt-pad:4px] text-base\" : \"[--bt-h:34px] [--bt-pad:3px] text-sm\",\n        \"relative isolate inline-flex max-w-full items-center rounded-button border border-line-soft bg-hover p-(--bt-pad) font-body leading-none tracking-body [--bt-accent:var(--billing-accent,var(--green))]\",\n        className,\n      )}\n      data-size={size}\n    >\n      {thumb ? (\n        <motion.span\n          className={cn(THUMB, \"top-(--bt-pad) bottom-(--bt-pad) left-0 will-change-[transform,width]\")}\n          aria-hidden=\"true\"\n          initial={false}\n          animate={{ x: thumb.x, width: thumb.width }}\n          transition={reduced ? { duration: 0 } : glide}\n        />\n      ) : null}\n      {options.map((option, index) => {\n        const selected = index === selectedIndex;\n        const badgeText = selected ? (option.activeBadge ?? option.badge) : option.badge;\n        const candidates = [option.badge, option.activeBadge].filter((text): text is string => !!text);\n        return (\n          // A button keeps the label and savings badge inside one focusable option; the role and arrow keys follow the radio pattern.\n          <button\n            key={option.value}\n            ref={(node) => {\n              refs.current[index] = node;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            tabIndex={selected ? 0 : -1}\n            className={cn(\n              \"group/option relative inline-flex h-(--bt-h) min-w-0 items-center rounded-button border-0 bg-transparent [font:inherit] tracking-[inherit] whitespace-nowrap [-webkit-tap-highlight-color:transparent] transition-[color] duration-fast ease-standard\",\n              size === \"lg\"\n                ? badgeText\n                  ? \"pr-1.5 pl-[18px]\"\n                  : \"px-[18px]\"\n                : badgeText\n                  ? \"pr-[5px] pl-[14px]\"\n                  : \"px-[14px]\",\n              selected ? \"cursor-default text-ink\" : \"cursor-pointer text-ink-3 hover:text-ink-2\",\n            )}\n            data-selected={selected || undefined}\n            onClick={() => onValueChange(option.value)}\n            onKeyDown={(event) => onKeyDown(event, index)}\n          >\n            {selected && !thumb ? <span className={cn(THUMB, \"inset-0\")} aria-hidden=\"true\" /> : null}\n            <span\n              className={cn(\n                \"relative inline-flex items-center gap-2 transition-transform duration-fast ease-standard motion-reduce:transition-none\",\n                !selected && \"group-active/option:scale-[.97]\",\n              )}\n            >\n              <span className=\"font-medium\">{option.label}</span>\n              {badgeText ? (\n                // The savings note: quiet until its option is chosen, then it takes the accent tint.\n                <span\n                  className={cn(\n                    \"inline-flex h-[calc(var(--bt-h)-10px)] items-center rounded-full px-2 text-xs leading-none font-medium tabular-nums transition-[background-color,color] duration-standard ease-standard motion-reduce:transition-none\",\n                    selected\n                      ? \"bg-[color-mix(in_oklab,var(--bt-accent)_13%,transparent)] text-[color-mix(in_oklab,var(--bt-accent)_88%,var(--ink))] dark:bg-[color-mix(in_oklab,var(--bt-accent)_18%,transparent)] dark:text-(color:--bt-accent)\"\n                      : \"bg-[color-mix(in_oklab,var(--ink)_6%,transparent)] text-ink-2\",\n                  )}\n                  data-active={selected || undefined}\n                >\n                  <StableSwap text={badgeText} candidates={candidates} reduced={reduced} />\n                </span>\n              ) : null}\n            </span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport interface BillingPriceProps {\n  amount: number;\n  currency?: string;\n  /** Period after the price, e.g. \"per month\". Swaps in place when it changes. */\n  period?: string;\n  /** Previous price, shown struck through when it is higher than `amount`. */\n  was?: number;\n  decimals?: number;\n  className?: string;\n}\n\nconst clip: Transition = { type: \"spring\", visualDuration: 0.36, bounce: 0 };\n\n/** A price that rolls to its new amount. The old price and the period ease their width, so nothing beside them jumps. */\nexport function BillingPrice({ amount, currency = \"$\", period, was, decimals = 0, className }: BillingPriceProps) {\n  const reduced = !!useReducedMotion();\n  const showWas = was !== undefined && was > amount;\n  const periodRef = useRef<HTMLSpanElement>(null);\n  const [periodWidth, setPeriodWidth] = useState<number | null>(null);\n\n  useLayoutEffect(() => {\n    const sizer = periodRef.current;\n    if (sizer) setPeriodWidth(sizer.offsetWidth);\n  }, [period]);\n\n  return (\n    <span className={cn(\"inline-flex min-w-0 flex-wrap items-end gap-x-[10px] gap-y-1\", className)}>\n      <span className=\"inline-flex items-start font-display text-3xl leading-none font-normal tracking-display text-ink tabular-nums\">\n        <span className=\"mt-[.12em] mr-[.04em] text-[.55em] text-ink-2\">{currency}</span>\n        <span className=\"inline-flex [&>span]:[font:inherit]! [&>span]:tracking-[inherit]!\">\n          <AnimatedCounter value={amount} decimals={decimals} />\n        </span>\n      </span>\n      <span className=\"inline-flex items-baseline pb-[.3em] text-sm leading-body whitespace-nowrap text-ink-3\">\n        <AnimatePresence initial={false}>\n          {showWas ? (\n            <motion.span\n              key=\"was\"\n              className=\"inline-block overflow-x-clip\"\n              initial={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}\n              animate={{ opacity: 1, width: \"auto\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : { opacity: 0, width: 0, transition: { ...clip, opacity: { duration: duration.fast } } }\n              }\n              transition={\n                reduced\n                  ? { duration: duration.fast }\n                  : { ...clip, opacity: { duration: duration.standard, ease: [...ease.enter] } }\n              }\n            >\n              <del className=\"inline-block pr-1.5 text-ink-3 tabular-nums decoration-1\">\n                {currency}\n                {was.toFixed(decimals)}\n              </del>\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n        {period ? (\n          <motion.span\n            className=\"relative inline-block overflow-x-clip [overflow-clip-margin:2px]\"\n            initial={false}\n            animate={periodWidth === null ? undefined : { width: periodWidth }}\n            transition={reduced ? { duration: 0 } : clip}\n          >\n            <span ref={periodRef} className=\"invisible inline-block\" aria-hidden=\"true\">\n              {period}\n            </span>\n            <AnimatePresence initial={false}>\n              <motion.span\n                key={period}\n                className=\"absolute top-0 left-0 inline-block\"\n                variants={reduced ? still : swap}\n                initial=\"hidden\"\n                animate=\"shown\"\n                exit=\"gone\"\n              >\n                {period}\n              </motion.span>\n            </AnimatePresence>\n          </motion.span>\n        ) : null}\n      </span>\n    </span>\n  );\n}\n\nexport default BillingToggle;\n",
      "type": "registry:component",
      "target": "@components/iris/billing-toggle/billing-toggle.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/billing-toggle",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "BillingToggle",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/billing-toggle"
  }
}
