{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json",
    "https://mcp.xzero.co.uk/r/animated-counter.json"
  ],
  "files": [
    {
      "path": "registry/components/billing-toggle/billing-toggle.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./billing-toggle.module.css\";\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: { opacity: 0, y: \"-0.45em\", filter: `blur(${blur.subtle}px)`, transition: { duration: duration.fast, ease: [...ease.standard] } },\n};\nconst still: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: duration.fast } }, gone: { opacity: 0, transition: { duration: 0 } } };\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({ text, candidates, reduced, className }: { text: string; candidates: string[]; reduced: boolean; className?: string }) {\n  return (\n    <span className={[styles.swap, className].filter(Boolean).join(\" \")}>\n      {[...new Set(candidates)].map(candidate => <span key={candidate} className={styles.swapSizer} aria-hidden=\"true\">{candidate}</span>)}\n      <AnimatePresence initial={false}>\n        <motion.span key={text} className={styles.swapText} variants={reduced ? still : swap} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{text}</motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\ntype Thumb = { x: number; width: number };\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({ value, onValueChange, options = DEFAULT_OPTIONS, label = \"Billing period\", size = \"md\", className }: 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(0, options.findIndex(option => option.value === value));\n\n  const measure = useCallback(() => {\n    const node = refs.current[selectedIndex];\n    if (!node) return;\n    setThumb(current => current && current.x === node.offsetLeft && current.width === node.offsetWidth ? current : { x: node.offsetLeft, width: node.offsetWidth });\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 = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 0;\n    const target = event.key === \"Home\" ? 0 : event.key === \"End\" ? options.length - 1 : step ? (index + step + options.length) % options.length : -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 ref={rootRef} role=\"radiogroup\" aria-label={label} className={[styles.root, className].filter(Boolean).join(\" \")} data-size={size}>\n      {thumb ? (\n        <motion.span\n          className={styles.thumb}\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          <button\n            key={option.value}\n            ref={node => { refs.current[index] = node; }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            tabIndex={selected ? 0 : -1}\n            className={styles.option}\n            data-selected={selected || undefined}\n            onClick={() => onValueChange(option.value)}\n            onKeyDown={event => onKeyDown(event, index)}\n          >\n            {selected && !thumb ? <span className={styles.thumbStatic} aria-hidden=\"true\" /> : null}\n            <span className={styles.content}>\n              <span className={styles.label}>{option.label}</span>\n              {badgeText ? (\n                <span className={styles.badge} data-active={selected || undefined}>\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={[styles.price, className].filter(Boolean).join(\" \")}>\n      <span className={styles.amount}>\n        <span className={styles.currency}>{currency}</span>\n        <span className={styles.counter}><AnimatedCounter value={amount} decimals={decimals} /></span>\n      </span>\n      <span className={styles.aside}>\n        <AnimatePresence initial={false}>\n          {showWas ? (\n            <motion.span\n              key=\"was\"\n              className={styles.wasSlot}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}\n              animate={{ opacity: 1, width: \"auto\" }}\n              exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, width: 0, transition: { ...clip, opacity: { duration: duration.fast } } }}\n              transition={reduced ? { duration: duration.fast } : { ...clip, opacity: { duration: duration.standard, ease: [...ease.enter] } }}\n            >\n              <del className={styles.was}>{currency}{was.toFixed(decimals)}</del>\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n        {period ? (\n          <motion.span\n            className={styles.period}\n            initial={false}\n            animate={periodWidth === null ? undefined : { width: periodWidth }}\n            transition={reduced ? { duration: 0 } : clip}\n          >\n            <span ref={periodRef} className={styles.periodSizer} aria-hidden=\"true\">{period}</span>\n            <AnimatePresence initial={false}>\n              <motion.span key={period} className={styles.periodText} variants={reduced ? still : swap} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{period}</motion.span>\n            </AnimatePresence>\n          </motion.span>\n        ) : null}\n      </span>\n    </span>\n  );\n}\n\nexport default BillingToggle;\n"
    },
    {
      "path": "registry/components/billing-toggle/billing-toggle.module.css",
      "type": "registry:component",
      "target": "@components/xzero/billing-toggle/billing-toggle.module.css",
      "content": "/* Override --billing-accent on any ancestor to recolor the savings note. */\n.root {\n  --_accent: var(--billing-accent, var(--success));\n  --_h: 34px;\n  --_pad: 3px;\n  position: relative;\n  isolation: isolate;\n  display: inline-flex;\n  max-width: 100%;\n  align-items: center;\n  padding: var(--_pad);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-pill);\n  background: var(--surface-muted);\n  font-family: var(--font-body);\n  font-size: var(--text-sm);\n  letter-spacing: var(--tracking-body);\n  line-height: 1;\n}\n.root[data-size=\"lg\"] { --_h: 42px; --_pad: 4px; font-size: var(--text-base); }\n\n/* One thumb for the whole track. Its position and width are measured from the selected option. */\n.thumb,\n.thumbStatic {\n  position: absolute;\n  z-index: -1;\n  border-radius: var(--radius-pill);\n  background: var(--surface-raised);\n  box-shadow: 0 0 0 1px var(--border-subtle), 0 1px 2px oklch(0% 0 0 / .06), 0 2px 6px oklch(0% 0 0 / .04);\n  pointer-events: none;\n}\n.thumb { top: var(--_pad); bottom: var(--_pad); left: 0; will-change: transform, width; }\n.thumbStatic { inset: 0; }\n:global(:root[data-theme=\"dark\"]) .thumb,\n:global(:root[data-theme=\"dark\"]) .thumbStatic {\n  box-shadow: 0 0 0 1px var(--border), inset 0 1px 0 oklch(100% 0 0 / .05), 0 1px 2px oklch(0% 0 0 / .3);\n}\n\n.option {\n  position: relative;\n  display: inline-flex;\n  min-width: 0;\n  height: var(--_h);\n  align-items: center;\n  padding: 0 14px;\n  border: 0;\n  border-radius: var(--radius-pill);\n  background: transparent;\n  color: var(--text-muted);\n  font: inherit;\n  letter-spacing: inherit;\n  white-space: nowrap;\n  cursor: pointer;\n  transition: color var(--duration-fast) var(--ease-standard);\n  -webkit-tap-highlight-color: transparent;\n}\n.option[data-selected] { color: var(--foreground); cursor: default; }\n.option:has(.badge) { padding-right: 5px; }\n.root[data-size=\"lg\"] .option { padding-inline: 18px; }\n.root[data-size=\"lg\"] .option:has(.badge) { padding-right: 6px; }\n\n@media (hover: hover) and (pointer: fine) {\n  .option:not([data-selected]):hover { color: var(--text-secondary); }\n}\n\n.content {\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  transition: transform var(--duration-fast) var(--ease-standard);\n}\n.option:not([data-selected]):active .content { transform: scale(.97); }\n\n.label { font-weight: 500; }\n\n/* The savings note: quiet until its option is chosen, then it takes the accent tint. */\n.badge {\n  display: inline-flex;\n  height: calc(var(--_h) - 10px);\n  align-items: center;\n  padding: 0 8px;\n  border-radius: var(--radius-pill);\n  background: color-mix(in oklab, var(--foreground) 6%, transparent);\n  color: var(--text-secondary);\n  font-size: var(--text-xs);\n  font-weight: 500;\n  font-variant-numeric: tabular-nums;\n  transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard);\n}\n.badge[data-active] {\n  background: color-mix(in oklab, var(--_accent) 13%, transparent);\n  color: color-mix(in oklab, var(--_accent) 88%, var(--foreground));\n}\n:global(:root[data-theme=\"dark\"]) .badge[data-active] {\n  background: color-mix(in oklab, var(--_accent) 18%, transparent);\n  color: var(--_accent);\n}\n\n/* Stacked swap: every candidate reserves the cell, the visible one crossfades on top. */\n.swap { position: relative; display: inline-grid; justify-items: center; overflow-y: clip; overflow-clip-margin: 2px; }\n.swapSizer { grid-area: 1 / 1; visibility: hidden; }\n.swapText { grid-area: 1 / 1; display: inline-block; }\n\n@media (prefers-reduced-motion: reduce) {\n  .content, .badge { transition: none; }\n}\n\n/* ------------------------------------------------------------------------ Price */\n\n.price { display: inline-flex; min-width: 0; flex-wrap: wrap; align-items: flex-end; gap: 4px 10px; }\n.amount {\n  display: inline-flex;\n  align-items: flex-start;\n  color: var(--foreground);\n  font-family: var(--font-display);\n  font-size: var(--text-3xl);\n  font-weight: 400;\n  letter-spacing: var(--tracking-display);\n  line-height: 1;\n  font-variant-numeric: tabular-nums;\n}\n.currency { margin-top: .12em; margin-right: .04em; color: var(--text-secondary); font-size: .55em; }\n.counter { display: inline-flex; }\n.counter > span { font: inherit !important; letter-spacing: inherit !important; }\n.aside {\n  display: inline-flex;\n  align-items: baseline;\n  padding-bottom: .3em;\n  color: var(--text-muted);\n  font-size: var(--text-sm);\n  line-height: var(--leading-body);\n  white-space: nowrap;\n}\n.wasSlot { display: inline-block; overflow-x: clip; }\n.was { display: inline-block; padding-right: 6px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-decoration-thickness: 1px; }\n.period { position: relative; display: inline-block; overflow-x: clip; overflow-clip-margin: 2px; }\n.periodSizer { display: inline-block; visibility: hidden; }\n.periodText { position: absolute; top: 0; left: 0; display: inline-block; }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/billing-toggle",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/billing-toggle",
    "markdown": "https://ui.xzero.co.uk/components/billing-toggle/markdown"
  }
}