{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/radio-cards/radio-cards.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} 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 { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\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(0.42, 0);\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nexport const RadioCards = forwardRef<HTMLDivElement, RadioCardsProps>(function RadioCards(\n  {\n    options,\n    value,\n    defaultValue = null,\n    onValueChange,\n    layout = \"grid\",\n    minColumnWidth = 180,\n    name,\n    required,\n    disabled = false,\n    className,\n    style,\n    ...rest\n  },\n  forwardedRef,\n) {\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),\n    y = useMotionValue(0),\n    w = useMotionValue(0),\n    h = useMotionValue(0),\n    o = useMotionValue(0);\n  const placed = useRef(false);\n  const place = useCallback(\n    (spring: boolean) => {\n      const node =\n        selectedIndex < 0 ? null : rootRef.current?.querySelector<HTMLElement>(`[data-card=\"${selectedIndex}\"]`);\n      if (!node) {\n        o.set(0);\n        placed.current = false;\n        return;\n      }\n      const box: [number, number, number, number] = [\n        node.offsetLeft,\n        node.offsetTop,\n        node.offsetWidth,\n        node.offsetHeight,\n      ];\n      if (!spring || !placed.current || reduced) {\n        x.jump(box[0]);\n        y.jump(box[1]);\n        w.jump(box[2]);\n        h.jump(box[3]);\n        if (!placed.current && !reduced && spring) {\n          o.jump(0);\n          animate(o, 1, { duration: motionTokens.duration.fast, ease: standard });\n        } else o.jump(1);\n        placed.current = true;\n        return;\n      }\n      animate(x, box[0], GLIDE);\n      animate(y, box[1], GLIDE);\n      animate(w, box[2], GLIDE);\n      animate(h, box[3], GLIDE);\n      o.set(1);\n    },\n    [h, o, reduced, selectedIndex, w, x, y],\n  );\n\n  const placeRef = useRef(place);\n  useLayoutEffect(() => {\n    placeRef.current = place;\n    place(true);\n  }, [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 =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? 1\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? -1\n          : 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 =\n      step &&\n      (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") &&\n      getComputedStyle(event.currentTarget).direction === \"rtl\"\n        ? -1\n        : 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]!)) {\n        cards()[at]?.focus();\n        select(options[at]!.value);\n        return;\n      }\n    }\n  };\n\n  const grid = layout === \"grid\";\n  return (\n    <div\n      ref={rootRef}\n      role=\"radiogroup\"\n      aria-disabled={disabled || undefined}\n      aria-required={required || undefined}\n      className={cn(\n        \"relative grid gap-[10px] font-body text-sm leading-body tracking-body text-ink [&_*]:box-border\",\n        grid\n          ? \"grid-cols-[repeat(auto-fill,minmax(min(100%,var(--min-column,180px)),1fr))]\"\n          : \"grid-cols-[minmax(0,1fr)] gap-2\",\n        className,\n      )}\n      data-layout={layout}\n      style={{ \"--min-column\": `${minColumnWidth}px`, ...style } as CSSProperties}\n      {...rest}\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      <motion.span\n        className=\"pointer-events-none absolute top-0 left-0 z-1 rounded-[20px] border-[1.5px] border-control-on\"\n        style={{ x, y, width: w, height: h, opacity: o }}\n        aria-hidden=\"true\"\n      />\n      {options.map((option, index) => {\n        const checked = index === selectedIndex,\n          off = !usable(option);\n        const labelId = `${uid}-${index}-label`,\n          descriptionId = `${uid}-${index}-description`;\n        const description = off && option.disabledReason ? option.disabledReason : option.description;\n        // A card holds a label, description, price and icon, which a native radio input cannot contain; the role and keys follow the radio pattern.\n        return (\n          <div\n            key={option.value}\n            role=\"radio\"\n            data-card={index}\n            className={cn(\n              \"relative grid min-w-0 cursor-pointer items-start gap-x-3 gap-y-1 rounded-[20px] border border-line bg-surface px-4 py-[14px] select-none [-webkit-tap-highlight-color:transparent] transition-[background-color,border-color] duration-fast ease-standard motion-reduce:transition-none\",\n              // The body row takes any spare height, so prices sit on one baseline across a row even when a description wraps.\n              grid\n                ? \"grid-cols-[minmax(0,1fr)_auto] grid-rows-[minmax(0,1fr)_auto] [grid-template-areas:'body_indicator'_'meta_meta']\"\n                : \"grid-cols-[auto_minmax(0,1fr)_auto] items-center py-[13px] pr-4 pl-[14px] [grid-template-areas:'indicator_body_meta']\",\n              checked && \"bg-[color-mix(in_oklab,var(--control-on)_4%,var(--surface))]\",\n              !checked && !off && \"hover:border-line-strong active:bg-hover\",\n              !checked && \"focus-visible:border-line-strong focus-visible:bg-hover\",\n              off && \"cursor-not-allowed bg-hover\",\n            )}\n            aria-checked={checked}\n            aria-disabled={off || undefined}\n            aria-labelledby={labelId}\n            aria-describedby={description ? descriptionId : undefined}\n            tabIndex={index === tabStop ? 0 : -1}\n            data-checked={checked || undefined}\n            onClick={() => {\n              if (!off) select(option.value);\n            }}\n            onKeyDown={(event) => onKeyDown(event, index)}\n          >\n            <span\n              className={cn(\n                \"grid size-[18px] flex-none place-items-center rounded-full border-[1.5px] border-line-strong transition-[border-color,background-color] duration-fast ease-standard [grid-area:indicator] motion-reduce:transition-none\",\n                grid && \"mt-px\",\n                checked && \"border-control-on bg-control-on\",\n                off && \"border-line\",\n              )}\n              aria-hidden=\"true\"\n            >\n              <motion.span\n                className=\"size-1.5 rounded-full bg-control-glyph\"\n                initial={false}\n                animate={{ scale: checked ? 1 : 0 }}\n                transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n              />\n            </span>\n            <span className=\"grid min-w-0 gap-0.5 [grid-area:body]\">\n              <span id={labelId} className={cn(\"flex min-w-0 items-center gap-2 font-medium\", off && \"text-ink-3\")}>\n                {option.icon && (\n                  <span\n                    className={cn(\n                      \"grid size-[18px] flex-none place-items-center text-ink-2 [&>svg]:size-[18px]\",\n                      checked && \"text-ink\",\n                    )}\n                  >\n                    {option.icon}\n                  </span>\n                )}\n                <span className=\"min-w-0 truncate\">{option.label}</span>\n              </span>\n              {description && (\n                <span id={descriptionId} className=\"text-xs leading-body text-pretty text-ink-2\">\n                  {description}\n                </span>\n              )}\n            </span>\n            {option.meta && (\n              <span\n                className={cn(\n                  \"text-ink tabular-nums whitespace-nowrap [grid-area:meta]\",\n                  grid ? \"mt-[10px] text-lg leading-body\" : \"font-medium\",\n                  off && \"text-ink-3\",\n                )}\n              >\n                {option.meta}\n              </span>\n            )}\n          </div>\n        );\n      })}\n      {name && <input type=\"hidden\" name={name} value={selected ?? \"\"} required={required} />}\n    </div>\n  );\n});\n\nexport default RadioCards;\n",
      "type": "registry:component",
      "target": "@components/iris/radio-cards/radio-cards.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/radio-cards",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "RadioCards",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/radio-cards"
  }
}
