{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/radio-group/radio-group.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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\";\nimport styles from \"./radio-group.module.css\";\nexport interface RadioGroupProps { label: string; options: { value: string; label: string; description?: string }[]; value: string; onValueChange: (value: string) => void; name?: string }\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(highlight: HTMLElement | null, row: HTMLElement | null | undefined, at: { current: string }, glide: boolean) {\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) { animate(highlight, { opacity: 0 }, { duration: 0 }); return; }\n  const target = { y: row.offsetTop, height: row.offsetHeight, opacity: 1 };\n  if (glide && visible) { animate(highlight, target, { ...motionTokens.spring.morph, opacity: { duration: 0 } }); return; }\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, { transform: `translateY(${target.y}px)`, height: `${target.height}px`, opacity: \"1\" });\n  animate(highlight, target, { duration: 0 });\n}\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(highlightRef.current, rows.current[selected], at, shown.current !== null && shown.current !== selected && !reduced);\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(() => place(highlightRef.current, rows.current[shown.current ?? -1], at, false));\n    observer.observe(list); rows.current.forEach(row => row && observer.observe(row));\n    return () => observer.disconnect();\n  }, [options.length]);\n  return <fieldset className={styles.group}><legend>{label}</legend><div ref={listRef} className={styles.options}>\n    <span ref={highlightRef} className={styles.highlight} aria-hidden=\"true\" />\n    {options.map((option, index) => { const checked = value === option.value; return <label className={styles.option} key={option.value} ref={node => { rows.current[index] = node; }}>\n      <input type=\"radio\" name={name ?? id} value={option.value} checked={checked} onChange={() => onValueChange(option.value)}/>\n      <span className={styles.mark} aria-hidden=\"true\"><motion.span className={styles.dot} initial={false} animate={checked ? { scale: 1, opacity: 1 } : { scale: .4, opacity: 0 }} transition={reduced ? { duration: 0 } : { ...motionTokens.spring.snappy, opacity: { duration: checked ? motionTokens.duration.fast : motionTokens.duration.instant } }}/></span>\n      <span><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span>\n    </label>; })}\n  </div></fieldset>;\n}\n"
    },
    {
      "path": "registry/components/radio-group/radio-group.module.css",
      "type": "registry:component",
      "target": "@components/xzero/radio-group/radio-group.module.css",
      "content": ".group { display: grid; min-width: 0; margin: 0; border: 0; padding: 0; }\n.group legend { margin-bottom: var(--space-2); padding: 0; font-size: var(--text-sm); font-weight: 500; }\n/* One highlight travels between the rows: above their borders and fills, below their text, so it never crosses a label. */\n.options { position: relative; display: grid; gap: var(--space-2); }\n.highlight { position: absolute; top: 0; right: 0; left: 0; z-index: 1; box-sizing: border-box; border: 1px solid var(--control-on); border-radius: var(--radius-control); background: color-mix(in oklab, var(--control-on) 5%, transparent); opacity: 0; pointer-events: none; }\n/* Rows answer a press with color only; the mark is what moves. */\n/* The mark sits centered on the left edge of the row, whether or not the option has a description. */\n.option { position: relative; display: flex; align-items: center; gap: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-control); padding: var(--space-3); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .option:hover { background: var(--surface-muted); } }\n.option:active { background: var(--surface-muted); }\n/* Until the highlight is placed (server paint or no script), the checked row carries the same look itself. */\n.options:not([data-ready]) .option:has(input:checked) { border-color: var(--control-on); background: color-mix(in oklab, var(--control-on) 5%, transparent); }\n.option input { position: absolute; opacity: 0; }\n/* Rows are not stacking contexts, so their mark and copy rise above the highlight while their border and fill stay below it. */\n.option > span { position: relative; z-index: 2; }\n.mark { display: grid; width: 17px; height: 17px; flex: 0 0 auto; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n/* 7px keeps the dot on whole pixels inside the 15px interior of the mark. */\n.mark::after, .dot { grid-area: 1 / 1; width: 7px; height: 7px; border-radius: 50%; background: var(--foreground); }\n/* A faint dot previews the choice while the pointer is down, then the real dot springs in on release. */\n.mark::after { content: \"\"; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); }\n.option:active:not(:has(input:checked)) .mark::after { opacity: .22; }\n/* The mark alone answers a press: a quick dip, then a spring back. */\n.option:active .mark { transform: scale(.88); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform 100ms var(--ease-standard); }\n.option:has(input:checked) .mark { border-color: var(--control-on); background: var(--control-on); }\n.option:has(input:checked) .dot { background: var(--control-glyph); }\n.option strong, .option small { display: block; }\n.option strong { color: var(--text-secondary); font-size: var(--text-sm); font-weight: 500; transition: color var(--duration-standard) var(--ease-standard); }\n.option:has(input:checked) strong { color: var(--foreground); }\n.option small { margin-top: 2px; color: var(--text-muted); font-size: var(--text-xs); }\n@media (prefers-reduced-motion: reduce) { .option, .mark, .mark::after, .option strong { transition: none; } .option:active .mark { transform: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/radio-group",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "choice"
    ],
    "docs": "https://ui.xzero.co.uk/components/radio-group",
    "markdown": "https://ui.xzero.co.uk/components/radio-group/markdown"
  }
}