{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radiogroup",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["motion"],
  "devDependencies": [],
  "files": [
    {
      "path": "radiogroup.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst componentThemeClassName =\n  \"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]\";\n\nfunction getValidSelection(options: RadioOption[], candidate?: string) {\n  if (candidate === undefined) {\n    return options[0]?.value;\n  }\n\n  return options.some((option) => option.value === candidate)\n    ? candidate\n    : options[0]?.value;\n}\n\nexport interface RadioOption {\n  value: string;\n  label: string;\n  description?: string;\n}\n\nexport interface RadioGroupProps {\n  options: RadioOption[];\n  defaultValue?: string;\n  value?: string;\n  onChange?: (value: string) => void;\n  className?: string;\n  layoutId?: string;\n  name?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n}\n\nexport const RadioGroup: React.FC<RadioGroupProps> = ({\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  className,\n  defaultValue,\n  layoutId,\n  name,\n  onChange,\n  options,\n  value,\n}) => {\n  const generatedId = React.useId();\n  const isControlled = value !== undefined;\n  const [uncontrolledSelected, setUncontrolledSelected] = React.useState<\n    string | undefined\n  >(() => getValidSelection(options, defaultValue));\n  const inputRefs = React.useRef<Array<HTMLInputElement | null>>([]);\n  const selected = isControlled ? value : uncontrolledSelected;\n  const resolvedLayoutId = layoutId ?? `radio-active-bg-${generatedId}`;\n  const groupName = name ?? `radio-group-${generatedId}`;\n\n  React.useEffect(() => {\n    if (isControlled) {\n      return;\n    }\n\n    const normalized = getValidSelection(options, uncontrolledSelected);\n\n    if (normalized !== uncontrolledSelected) {\n      setUncontrolledSelected(normalized);\n    }\n  }, [isControlled, options, uncontrolledSelected]);\n\n  const handleSelect = React.useCallback(\n    (nextValue: string) => {\n      if (nextValue === selected) {\n        return;\n      }\n\n      if (!isControlled) {\n        setUncontrolledSelected(nextValue);\n      }\n\n      onChange?.(nextValue);\n    },\n    [isControlled, onChange, selected]\n  );\n\n  const focusAndSelect = React.useCallback(\n    (index: number) => {\n      const nextOption = options[index];\n\n      if (!nextOption) {\n        return;\n      }\n\n      inputRefs.current[index]?.focus();\n      handleSelect(nextOption.value);\n    },\n    [handleSelect, options]\n  );\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLInputElement>, index: number) => {\n      switch (event.key) {\n        case \"ArrowDown\":\n        case \"ArrowRight\": {\n          event.preventDefault();\n          focusAndSelect((index + 1) % options.length);\n          break;\n        }\n        case \"ArrowUp\":\n        case \"ArrowLeft\": {\n          event.preventDefault();\n          focusAndSelect((index - 1 + options.length) % options.length);\n          break;\n        }\n        case \"Home\": {\n          event.preventDefault();\n          focusAndSelect(0);\n          break;\n        }\n        case \"End\": {\n          event.preventDefault();\n          focusAndSelect(options.length - 1);\n          break;\n        }\n        default:\n          break;\n      }\n    },\n    [focusAndSelect, options.length]\n  );\n\n  if (options.length === 0) {\n    return null;\n  }\n\n  const tabStopValue = getValidSelection(options, selected);\n\n  return (\n    <div\n      aria-label={ariaLabel}\n      aria-labelledby={ariaLabelledBy}\n      aria-orientation=\"vertical\"\n      className={cn(\n        componentThemeClassName,\n        \"flex flex-col gap-0.5\",\n        className\n      )}\n      role=\"radiogroup\"\n    >\n      {options.map((option, index) => {\n        const isSelected = selected === option.value;\n        const optionId = `${generatedId}-option-${index}`;\n        const labelId = `${optionId}-label`;\n        const descriptionId = option.description\n          ? `${optionId}-description`\n          : undefined;\n\n        return (\n          <motion.label\n            animate={{ opacity: 1, y: 0 }}\n            className=\"block cursor-pointer\"\n            initial={{ opacity: 0, y: 8 }}\n            key={option.value}\n            transition={{ delay: index * 0.05, duration: 0.3 }}\n            whileHover={{ x: 2 }}\n            whileTap={{ scale: 0.96 }}\n          >\n            <input\n              aria-describedby={descriptionId}\n              aria-labelledby={labelId}\n              checked={isSelected}\n              className=\"peer sr-only\"\n              name={groupName}\n              onChange={() => handleSelect(option.value)}\n              onKeyDown={(event) => handleKeyDown(event, index)}\n              ref={(node) => {\n                inputRefs.current[index] = node;\n              }}\n              tabIndex={option.value === tabStopValue ? 0 : -1}\n              type=\"radio\"\n              value={option.value}\n            />\n\n            <div className=\"relative flex touch-manipulation select-none items-center gap-3.5 rounded-lg px-4 py-3.5 text-left outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-ring\">\n              {isSelected && (\n                <motion.div\n                  aria-hidden\n                  className=\"absolute inset-0 rounded-lg bg-foreground/[0.04]\"\n                  layoutId={resolvedLayoutId}\n                  transition={{\n                    type: \"spring\",\n                    stiffness: 300,\n                    damping: 28,\n                    mass: 0.8,\n                  }}\n                />\n              )}\n\n              <div\n                aria-hidden\n                className=\"relative z-10 flex h-5 w-5 shrink-0 items-center justify-center\"\n              >\n                <motion.div\n                  animate={{\n                    borderWidth: isSelected ? 6 : 2,\n                    borderColor: isSelected\n                      ? \"var(--ic-primary)\"\n                      : \"color-mix(in srgb, var(--ic-muted-foreground) 25%, transparent)\",\n                  }}\n                  className=\"absolute inset-0 rounded-full\"\n                  style={{ borderStyle: \"solid\" }}\n                  transition={{ type: \"spring\", stiffness: 400, damping: 22 }}\n                />\n\n                <AnimatePresence>\n                  {isSelected && (\n                    <motion.div\n                      animate={{ scale: 1, opacity: 1 }}\n                      className=\"absolute h-1.5 w-1.5 rounded-full bg-background\"\n                      exit={{ scale: 0.95, opacity: 0 }}\n                      initial={{ scale: 0.95, opacity: 0 }}\n                      transition={{\n                        type: \"spring\",\n                        stiffness: 500,\n                        damping: 25,\n                        delay: 0.05,\n                      }}\n                    />\n                  )}\n                </AnimatePresence>\n              </div>\n\n              <div className=\"relative z-10 flex flex-col gap-0.5\">\n                <motion.span\n                  animate={{\n                    color: isSelected\n                      ? \"var(--ic-foreground)\"\n                      : \"var(--ic-muted-foreground)\",\n                    fontWeight: isSelected ? 500 : 400,\n                  }}\n                  className=\"text-[14px] leading-tight\"\n                  id={labelId}\n                  transition={{ duration: 0.2 }}\n                >\n                  {option.label}\n                </motion.span>\n\n                {option.description && (\n                  <motion.span\n                    animate={{ opacity: isSelected ? 0.85 : 0.5 }}\n                    className=\"text-muted-foreground/60 text-xs leading-snug\"\n                    id={descriptionId}\n                    transition={{ duration: 0.2 }}\n                  >\n                    {option.description}\n                  </motion.span>\n                )}\n              </div>\n            </div>\n          </motion.label>\n        );\n      })}\n    </div>\n  );\n};\n\nexport default RadioGroup;\n",
      "type": "registry:ui"
    }
  ],
  "title": "Radio group",
  "description": "Single-select option list with shared layoutId highlight, spring ring/dot on the control, staggered row entrance, and optional descriptions. Motion only — native button radios with role=radiogroup."
}
