{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"radio-group","type":"registry:component","title":"Radio group","description":"One choice from a few: the dot pops into the new circle, or, as cards, one selection frame glides from card to card.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/radio-group.tsx","type":"registry:component","content":"// Hairline UI · radio-group · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, EASE_THROW, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * RADIO GROUP: one choice, one moving mark\n *\n *   list    the dot shrinks out of the old circle as it pops\n *           into the new one (a slight overshoot)\n *   cards   options are cards; the selection is one frame that\n *           glides from card to card, its height easing when\n *           the cards differ, instead of rings blinking\n *\n * Real radio inputs underneath: arrow keys, forms and screen\n * readers behave exactly as native radios do.\n * ───────────────────────────────────────────────────────── */\n\nexport interface RadioOption {\n  value: string;\n  label: React.ReactNode;\n  description?: React.ReactNode;\n  /** Right side of a card, e.g. a price. */\n  aside?: React.ReactNode;\n  disabled?: boolean;\n}\n\nexport interface RadioGroupProps {\n  options: RadioOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Form name; one is made up if you don't pass it. */\n  name?: string;\n  /** Accessible name of the group (shown as a legend when `showLabel`). */\n  label: string;\n  showLabel?: boolean;\n  variant?: \"list\" | \"cards\";\n  orientation?: \"vertical\" | \"horizontal\";\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Mark = { y: number; x: number; w: number; h: number };\n\nexport function RadioGroup({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  name,\n  label,\n  showLabel = false,\n  variant = \"list\",\n  orientation = \"vertical\",\n  disabled,\n  className = \"\",\n}: RadioGroupProps) {\n  const reduced = useInteractionReducedMotion();\n  const auto = React.useId();\n  const group = name ?? auto;\n  const [own, setOwn] = React.useState(defaultValue ?? null);\n  const current = value !== undefined ? value : own;\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const [mark, setMark] = React.useState<Mark | null>(null);\n  const [ready, setReady] = React.useState(false);\n  const cards = variant === \"cards\";\n\n  React.useLayoutEffect(() => {\n    if (!cards) return;\n    const list = listRef.current;\n    if (!list) return;\n    const measure = () => {\n      const el = current ? list.querySelector<HTMLElement>(`[data-radio=\"${CSS.escape(current)}\"]`) : null;\n      if (!el) return setMark(null);\n      const next = { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight };\n      setMark((p) => (p && p.x === next.x && p.y === next.y && p.w === next.w && p.h === next.h ? p : next));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(list);\n    return () => ro.disconnect();\n  }, [cards, current, options]);\n\n  React.useEffect(() => {\n    if (!mark || ready) return;\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, [mark, ready]);\n\n  const pick = (v: string) => {\n    if (value === undefined) setOwn(v);\n    onValueChange?.(v);\n  };\n\n  return (\n    <fieldset className={`m-0 min-w-0 border-0 p-0 ${className}`} disabled={disabled}>\n      <legend className={showLabel ? \"mb-2 px-0.5 text-[13px] font-medium text-foreground\" : \"sr-only\"}>{label}</legend>\n      <div\n        ref={listRef}\n        className={`relative flex ${orientation === \"horizontal\" ? \"flex-row flex-wrap\" : \"flex-col\"} ${cards ? \"gap-2\" : orientation === \"horizontal\" ? \"gap-x-5 gap-y-2\" : \"gap-2.5\"}`}\n      >\n        {cards && (\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-0 top-0 z-10 rounded-[14px] shadow-[inset_0_0_0_1.5px_var(--primary)]\"\n            style={{\n              transform: `translate3d(${mark?.x ?? 0}px, ${mark?.y ?? 0}px, 0)`,\n              width: mark?.w ?? 0,\n              height: mark?.h ?? 0,\n              opacity: mark ? 1 : 0,\n              transition: reduced || !ready ? \"none\" : `transform 420ms ${EASE_THROW}, width 380ms ${EASE_OUT}, height 380ms ${EASE_OUT}, opacity 200ms ${EASE_OUT}`,\n            }}\n          />\n        )}\n        {options.map((option) => {\n          const on = current === option.value;\n          const off = disabled || option.disabled;\n          return (\n            <label\n              key={option.value}\n              data-radio={option.value}\n              className={`group/radio relative flex select-none items-start gap-2.5 ${off ? \"cursor-default opacity-50\" : \"cursor-pointer\"} ${\n                cards\n                  ? `rounded-[14px] bg-background p-3.5 shadow-[inset_0_0_0_1px_var(--border)] transition-[background-color] duration-200 ${on ? \"bg-accent/50\" : \"hover:bg-accent/40\"}`\n                  : \"\"\n              }`}\n            >\n              <span className=\"relative mt-[2px] inline-flex size-4 shrink-0\">\n                <input\n                  type=\"radio\"\n                  name={group}\n                  value={option.value}\n                  checked={on}\n                  disabled={off}\n                  onChange={() => pick(option.value)}\n                  className=\"peer absolute inset-0 m-0 size-full cursor-[inherit] appearance-none rounded-full outline-none\"\n                />\n                <span\n                  aria-hidden=\"true\"\n                  className={`pointer-events-none absolute inset-0 rounded-full bg-background transition-[box-shadow,scale] duration-200 group-active/radio:scale-[0.88] peer-focus-visible:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_20%,transparent)] motion-reduce:group-active/radio:scale-100 ${\n                    on ? \"shadow-[inset_0_0_0_1.5px_var(--primary)]\" : \"shadow-[inset_0_0_0_1px_var(--border)] group-hover/radio:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_30%,var(--border))]\"\n                  }`}\n                >\n                  <span\n                    className=\"absolute inset-[4px] rounded-full bg-primary\"\n                    style={{ transform: on ? \"scale(1)\" : \"scale(0)\", transition: reduced ? \"none\" : on ? `transform 380ms ${EASE_THROW}` : `transform 200ms ${EASE_OUT}` }}\n                  />\n                </span>\n              </span>\n              <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                <span className=\"text-[13.5px] font-medium leading-5 text-foreground\">{option.label}</span>\n                {option.description && <span className=\"text-[12.5px] leading-snug text-muted-foreground\">{option.description}</span>}\n              </span>\n              {option.aside && <span className=\"shrink-0 text-[13px] tabular-nums text-foreground\">{option.aside}</span>}\n            </label>\n          );\n        })}\n      </div>\n    </fieldset>\n  );\n}\n\nexport default RadioGroup;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/radio-group. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}