{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/radio-group/radio-group.tsx","type":"registry:component","target":"@components/arc/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(null);\n const highlightRef = useRef(null);\n const rows = useRef<(HTMLLabelElement | null)[]>([]);\n const shown = useRef(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
{label}
\n \n {options.map((option, index) => { const checked = value === option.value; return ; })}\n
;\n}\n"},{"path":"registry/components/radio-group/radio-group.module.css","type":"registry:component","target":"@components/arc/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://uiarc.dev/components/radio-group","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/radio-group","markdown":"https://uiarc.dev/components/radio-group/markdown","tags":["field","choice"]}}