{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radio-cards",
  "type": "registry:ui",
  "title": "Radio cards",
  "description": "Selectable option cards with a sliding selection ring, price and description slots, and radio keyboard behavior.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/radio-cards/radio-cards.tsx",
      "type": "registry:component",
      "target": "@components/xzero/radio-cards/radio-cards.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./radio-cards.module.css\";\n\nexport interface RadioCardOption {\n  value: string;\n  label: ReactNode;\n  /** One or two short lines under the label. */\n  description?: ReactNode;\n  /** A price, estimate, or other value. Sits at the end of a list row, or under the text in a grid card. */\n  meta?: ReactNode;\n  /** Plain decorative icon beside the label. */\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** Short reason shown in place of the description when the option is disabled. */\n  disabledReason?: ReactNode;\n}\n\n/**\n * Selectable option cards for choices that need more than a label: plans, shipping speeds, regions. One selection ring\n * glides from card to card on a spring, so the eye follows the change. Behaves as a native radio group: one tab stop,\n * arrow keys move and select, and a hidden input carries the value in forms.\n */\nexport interface RadioCardsProps extends Omit<HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  options: RadioCardOption[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string) => void;\n  /** \"grid\" places cards in responsive columns; \"list\" stacks full width rows. */\n  layout?: \"grid\" | \"list\";\n  /** Narrowest a grid column may get before the grid drops a column, in px. */\n  minColumnWidth?: number;\n  /** Form field name. Renders a hidden input with the selected value. */\n  name?: string;\n  required?: boolean;\n  disabled?: boolean;\n}\n\ntype Bezier = [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as Bezier;\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GLIDE = physical(.42, 0);\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nexport const RadioCards = forwardRef<HTMLDivElement, RadioCardsProps>(function RadioCards({\n  options, value, defaultValue = null, onValueChange, layout = \"grid\", minColumnWidth = 180, name, required, disabled = false, className, style, ...rest\n}, forwardedRef) {\n  const reduced = useReducedFlag();\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  useImperativeHandle(forwardedRef, () => rootRef.current as HTMLDivElement);\n  const [internal, setInternal] = useState<string | null>(defaultValue);\n  const selected = value !== undefined ? value : internal;\n  const selectedIndex = options.findIndex(option => option.value === selected);\n  const usable = (option: RadioCardOption) => !disabled && !option.disabled;\n  const tabStop = selectedIndex >= 0 && usable(options[selectedIndex]!) ? selectedIndex : options.findIndex(usable);\n\n  const select = (next: string) => {\n    if (next === selected) return;\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  /* The ring: one element that springs to the selected card's box. At rest it follows layout changes exactly. */\n  const x = useMotionValue(0), y = useMotionValue(0), w = useMotionValue(0), h = useMotionValue(0), o = useMotionValue(0);\n  const placed = useRef(false);\n  const place = useCallback((spring: boolean) => {\n    const node = selectedIndex < 0 ? null : rootRef.current?.querySelector<HTMLElement>(`[data-card=\"${selectedIndex}\"]`);\n    if (!node) { o.set(0); placed.current = false; return; }\n    const box: [number, number, number, number] = [node.offsetLeft, node.offsetTop, node.offsetWidth, node.offsetHeight];\n    if (!spring || !placed.current || reduced) {\n      x.jump(box[0]); y.jump(box[1]); w.jump(box[2]); h.jump(box[3]);\n      if (!placed.current && !reduced && spring) { o.jump(0); animate(o, 1, { duration: motionTokens.duration.fast, ease: standard }); } else o.jump(1);\n      placed.current = true;\n      return;\n    }\n    animate(x, box[0], GLIDE); animate(y, box[1], GLIDE); animate(w, box[2], GLIDE); animate(h, box[3], GLIDE);\n    o.set(1);\n  }, [h, o, reduced, selectedIndex, w, x, y]);\n\n  const placeRef = useRef(place);\n  useLayoutEffect(() => { placeRef.current = place; place(true); }, [place]);\n  // A resize only snaps the ring to the new layout; it never replays the glide.\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => placeRef.current(false));\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, []);\n\n  const cards = () => Array.from(rootRef.current?.querySelectorAll<HTMLElement>(\"[data-card]\") ?? []);\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {\n    const step = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 0;\n    if (event.key === \" \" || event.key === \"Enter\") {\n      event.preventDefault();\n      if (usable(options[index]!)) select(options[index]!.value);\n      return;\n    }\n    if (!step && event.key !== \"Home\" && event.key !== \"End\") return;\n    event.preventDefault();\n    const count = options.length;\n    const rtl = step && (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") && getComputedStyle(event.currentTarget).direction === \"rtl\" ? -1 : 1;\n    let at = event.key === \"Home\" ? -1 : event.key === \"End\" ? count : index;\n    const dir = event.key === \"Home\" ? 1 : event.key === \"End\" ? -1 : step * rtl;\n    for (let tries = 0; tries < count; tries++) {\n      at = (at + dir + count) % count;\n      if (usable(options[at]!)) { cards()[at]?.focus(); select(options[at]!.value); return; }\n    }\n  };\n\n  return <div ref={rootRef} role=\"radiogroup\" aria-disabled={disabled || undefined} aria-required={required || undefined}\n    className={[styles.root, className].filter(Boolean).join(\" \")} data-layout={layout}\n    style={{ \"--min-column\": `${minColumnWidth}px`, ...style } as CSSProperties} {...rest}>\n    <motion.span className={styles.ring} style={{ x, y, width: w, height: h, opacity: o }} aria-hidden=\"true\" />\n    {options.map((option, index) => {\n      const checked = index === selectedIndex, off = !usable(option);\n      const labelId = `${uid}-${index}-label`, descriptionId = `${uid}-${index}-description`;\n      const description = off && option.disabledReason ? option.disabledReason : option.description;\n      return <div key={option.value} role=\"radio\" data-card={index} className={styles.card} aria-checked={checked} aria-disabled={off || undefined}\n        aria-labelledby={labelId} aria-describedby={description ? descriptionId : undefined} tabIndex={index === tabStop ? 0 : -1}\n        data-checked={checked || undefined} onClick={() => { if (!off) select(option.value); }} onKeyDown={event => onKeyDown(event, index)}>\n        <span className={styles.indicator} aria-hidden=\"true\">\n          <motion.span className={styles.indicatorDot} initial={false} animate={{ scale: checked ? 1 : 0 }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy} />\n        </span>\n        <span className={styles.body}>\n          <span id={labelId} className={styles.label}>\n            {option.icon && <span className={styles.icon}>{option.icon}</span>}\n            <span className={styles.labelText}>{option.label}</span>\n          </span>\n          {description && <span id={descriptionId} className={styles.description}>{description}</span>}\n        </span>\n        {option.meta && <span className={styles.meta}>{option.meta}</span>}\n      </div>;\n    })}\n    {name && <input type=\"hidden\" name={name} value={selected ?? \"\"} required={required} />}\n  </div>;\n});\n\nexport default RadioCards;\n"
    },
    {
      "path": "registry/components/radio-cards/radio-cards.module.css",
      "type": "registry:component",
      "target": "@components/xzero/radio-cards/radio-cards.module.css",
      "content": ".root {\n  position: relative;\n  display: grid;\n  gap: 10px;\n  color: var(--foreground);\n  font-family: var(--font-body);\n  font-size: var(--text-sm);\n  letter-spacing: var(--tracking-body);\n  line-height: var(--leading-body);\n}\n.root *, .root *::before, .root *::after { box-sizing: border-box; }\n.root[data-layout=\"grid\"] { grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--min-column, 180px)), 1fr)); }\n.root[data-layout=\"list\"] { grid-template-columns: minmax(0, 1fr); gap: 8px; }\n\n/* The one moving part: a ring that springs from card to card. It sits over the card border so the card itself never changes size. */\n.ring {\n  position: absolute;\n  z-index: 1;\n  top: 0;\n  left: 0;\n  border: 1.5px solid var(--control-on);\n  border-radius: 20px;\n  pointer-events: none;\n}\n\n.card {\n  position: relative;\n  display: grid;\n  min-width: 0;\n  align-items: start;\n  gap: 4px 12px;\n  padding: 14px 16px;\n  border: 1px solid var(--border);\n  border-radius: 20px;\n  background: var(--surface);\n  cursor: pointer;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);\n}\n/* The body row takes any spare height, so prices sit on one baseline across a row even when a description wraps. */\n.root[data-layout=\"grid\"] .card { grid-template-areas: \"body indicator\" \"meta meta\"; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) auto; }\n.root[data-layout=\"list\"] .card { grid-template-areas: \"indicator body meta\"; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; padding: 13px 16px 13px 14px; }\n.card[data-checked] { background: color-mix(in oklab, var(--control-on) 4%, var(--surface)); }\n.card[aria-disabled=\"true\"] { background: var(--surface-muted); cursor: not-allowed; }\n.card[aria-disabled=\"true\"] :is(.label, .meta) { color: var(--text-muted); }\n\n.indicator {\n  display: grid;\n  width: 18px;\n  height: 18px;\n  flex: none;\n  place-items: center;\n  grid-area: indicator;\n  border: 1.5px solid var(--border-strong);\n  border-radius: 50%;\n  transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.root[data-layout=\"grid\"] .indicator { margin-top: 1px; }\n.card[data-checked] .indicator { border-color: var(--control-on); background: var(--control-on); }\n.indicatorDot { width: 6px; height: 6px; border-radius: 50%; background: var(--control-glyph); }\n.card[aria-disabled=\"true\"] .indicator { border-color: var(--border); }\n\n.body { display: grid; min-width: 0; gap: 2px; grid-area: body; }\n.label { display: flex; min-width: 0; align-items: center; gap: 8px; font-weight: 500; }\n.labelText { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.icon { display: grid; width: 18px; height: 18px; flex: none; place-items: center; color: var(--text-secondary); }\n.icon > svg { width: 18px; height: 18px; }\n.card[data-checked] .icon { color: var(--foreground); }\n.description { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); text-wrap: pretty; }\n\n.meta { grid-area: meta; color: var(--foreground); font-variant-numeric: tabular-nums; white-space: nowrap; }\n.root[data-layout=\"grid\"] .meta { margin-top: 10px; font-size: var(--text-lg); }\n.root[data-layout=\"list\"] .meta { font-weight: 500; }\n\n@media (hover: hover) and (pointer: fine) {\n  .card:not([data-checked]):not([aria-disabled=\"true\"]):hover { border-color: var(--border-strong); }\n}\n.card:not([data-checked]):not([aria-disabled=\"true\"]):active { background: var(--surface-muted); }\n.card:focus-visible:not([data-checked]) { border-color: var(--border-strong); background: var(--surface-muted); }\n\n@media (prefers-reduced-motion: reduce) {\n  .card, .indicator { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/radio-cards",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/radio-cards",
    "markdown": "https://ui.xzero.co.uk/components/radio-cards/markdown"
  }
}