{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radio-group",
  "type": "registry:ui",
  "title": "Radio group",
  "description": "Choose one option from a visible set.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/radio-group/radio-group.tsx",
      "content": "\"use client\";\nimport { useEffect, useId, useLayoutEffect, useRef } from \"react\";\nimport { animate, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nexport interface RadioGroupProps {\n  label: string;\n  options: { value: string; label: string; description?: string }[];\n  value: string;\n  onValueChange: (value: string) => void;\n  name?: string;\n}\n\n/** Sizes the highlight to the chosen row. A new choice glides on the morph spring; the first paint and resizes place it at once. */\nfunction place(\n  highlight: HTMLElement | null,\n  row: HTMLElement | null | undefined,\n  at: { current: string },\n  glide: boolean,\n) {\n  const next = row ? `${row.offsetTop} ${row.offsetHeight}` : \"\";\n  if (!highlight || next === at.current) return;\n  const visible = at.current !== \"\";\n  at.current = next;\n  if (!row) {\n    animate(highlight, { opacity: 0 }, { duration: 0 });\n    return;\n  }\n  const target = { y: row.offsetTop, height: row.offsetHeight, opacity: 1 };\n  if (glide && visible) {\n    animate(highlight, target, { ...motionTokens.spring.morph, opacity: { duration: 0 } });\n    return;\n  }\n  // Written to the element too, so the paint that drops the server fallback already shows the highlight in place.\n  Object.assign(highlight.style, {\n    transform: `translateY(${target.y}px)`,\n    height: `${target.height}px`,\n    opacity: \"1\",\n  });\n  animate(highlight, target, { duration: 0 });\n}\n\n/* Rows answer a press with color only; the mark is what moves. The mark sits centered on the left edge of the row, whether or not the\n   option has a description. Until the highlight is placed (server paint or no script), the checked row carries the same look itself. */\nconst rowClass =\n  \"group/option relative flex cursor-pointer items-center gap-3 rounded-control border border-line p-3 transition-[background-color,border-color] duration-fast ease-standard [-webkit-tap-highlight-color:transparent] hover:bg-hover active:bg-hover motion-reduce:transition-none [:not([data-ready])>&]:has-checked:border-control-on [:not([data-ready])>&]:has-checked:bg-[color-mix(in_oklab,var(--control-on)_5%,transparent)]\";\n/* Rows are not stacking contexts, so their mark and copy rise above the highlight while their border and fill stay below it. A faint dot\n   (7px keeps it on whole pixels inside the 15px interior) previews the choice while the pointer is down, then the real dot springs in on\n   release; the mark alone answers a press with a quick dip and a spring back. */\nconst mark = [\n  \"relative z-2 grid size-[17px] flex-none place-items-center rounded-full border border-line-strong [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] group-has-checked/option:border-control-on group-has-checked/option:bg-control-on\",\n  \"after:col-start-1 after:row-start-1 after:size-[7px] after:rounded-full after:bg-ink after:opacity-0 after:transition-opacity after:duration-instant after:ease-standard after:content-[''] [:active:not(:has(:checked))>&]:after:opacity-22\",\n  \"group-active/option:[transform:scale(.88)] group-active/option:[transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),transform_100ms_var(--ease-standard)]\",\n  \"motion-reduce:transition-none motion-reduce:group-active/option:transform-none motion-reduce:group-active/option:transition-none motion-reduce:after:transition-none\",\n].join(\" \");\n\n/** One highlight travels to the chosen row while the new dot springs in and the old one shrinks away, so a change reads as a single physical move. Arrow keys take the same path. Rows and text never resize. */\nexport function RadioGroup({ label, options, value, onValueChange, name }: RadioGroupProps) {\n  const id = useId();\n  const reduced = useReducedMotion();\n  const listRef = useRef<HTMLDivElement>(null);\n  const highlightRef = useRef<HTMLSpanElement>(null);\n  const rows = useRef<(HTMLLabelElement | null)[]>([]);\n  const shown = useRef<number | null>(null);\n  const at = useRef(\"\");\n  const selected = options.findIndex((option) => option.value === value);\n  useLayoutEffect(() => {\n    place(\n      highlightRef.current,\n      rows.current[selected],\n      at,\n      shown.current !== null && shown.current !== selected && !reduced,\n    );\n    shown.current = selected;\n    listRef.current?.setAttribute(\"data-ready\", \"\");\n  }, [selected, reduced]);\n  useEffect(() => {\n    const list = listRef.current;\n    if (!list || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() =>\n      place(highlightRef.current, rows.current[shown.current ?? -1], at, false),\n    );\n    observer.observe(list);\n    rows.current.forEach((row) => row && observer.observe(row));\n    return () => observer.disconnect();\n  }, [options.length]);\n  // One highlight travels between the rows: above their borders and fills, below their text, so it never crosses a label.\n  return (\n    <fieldset className=\"m-0 grid min-w-0 border-0 p-0\">\n      <legend className=\"mb-2 p-0 text-sm/[inherit] font-medium\">{label}</legend>\n      <div ref={listRef} className=\"relative grid gap-2\">\n        <span\n          ref={highlightRef}\n          className=\"pointer-events-none absolute top-0 right-0 left-0 z-1 box-border rounded-control border border-control-on bg-[color-mix(in_oklab,var(--control-on)_5%,transparent)] opacity-0\"\n          aria-hidden=\"true\"\n        />\n        {options.map((option, index) => {\n          const checked = value === option.value;\n          return (\n            <label\n              className={rowClass}\n              key={option.value}\n              ref={(node) => {\n                rows.current[index] = node;\n              }}\n            >\n              <input\n                className=\"absolute opacity-0\"\n                type=\"radio\"\n                name={name ?? id}\n                value={option.value}\n                checked={checked}\n                onChange={() => onValueChange(option.value)}\n              />\n              <span className={mark} aria-hidden=\"true\">\n                <motion.span\n                  className=\"col-start-1 row-start-1 size-[7px] rounded-full bg-ink group-has-checked/option:bg-control-glyph\"\n                  initial={false}\n                  animate={checked ? { scale: 1, opacity: 1 } : { scale: 0.4, opacity: 0 }}\n                  transition={\n                    reduced\n                      ? { duration: 0 }\n                      : {\n                          ...motionTokens.spring.snappy,\n                          opacity: { duration: checked ? motionTokens.duration.fast : motionTokens.duration.instant },\n                        }\n                  }\n                />\n              </span>\n              <span className=\"relative z-2\">\n                <strong className=\"block text-sm/[inherit] font-medium text-ink-2 transition-colors duration-standard ease-standard group-has-checked/option:text-ink motion-reduce:transition-none\">\n                  {option.label}\n                </strong>\n                {option.description && (\n                  <small className=\"mt-0.5 block text-xs/[inherit] text-ink-3\">{option.description}</small>\n                )}\n              </span>\n            </label>\n          );\n        })}\n      </div>\n    </fieldset>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/radio-group/radio-group.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/radio-group",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "RadioGroup",
    "tags": [
      "field",
      "choice"
    ],
    "docs": "https://irisui.dev/components/radio-group"
  }
}
