{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radio-group",
  "title": "Radio Group",
  "description": "A radio group with roving tabindex and a dot morph selection indicator.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/radio-group.tsx",
      "type": "registry:ui",
      "content": "'use client'\n\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from 'react'\nimport { motion, useReducedMotion } from 'motion/react'\nimport { cn } from '@/lib/utils'\nimport { springs } from '@/lib/motion-tokens'\nimport { playHoverSound, playClickSound } from '@/lib/sound'\n\ninterface RadioGroupContextValue {\n  value: string | undefined\n  setValue: (v: string) => void\n  name?: string\n  groupId: string\n  focusedValue: string | null\n  setFocusedValue: (v: string) => void\n  itemValues: string[]\n  registerItem: (v: string) => void\n  unregisterItem: (v: string) => void\n  required?: boolean\n  ariaInvalid?: boolean\n}\n\nconst RadioGroupContext = createContext<RadioGroupContextValue | null>(null)\n\nfunction useRadioGroupCtx(componentName: string) {\n  const ctx = useContext(RadioGroupContext)\n  if (!ctx) throw new Error(`${componentName} must be used within RadioGroup`)\n  return ctx\n}\n\nexport interface RadioGroupProps {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  name?: string\n  children?: ReactNode\n  className?: string\n  required?: boolean\n  'aria-invalid'?: boolean\n  'aria-describedby'?: string\n}\n\nexport const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(\n  (\n    {\n      value: valueProp,\n      defaultValue,\n      onValueChange,\n      name,\n      children,\n      className,\n      required,\n      'aria-invalid': ariaInvalid,\n      'aria-describedby': ariaDescribedby,\n      ...props\n    },\n    ref,\n  ) => {\n    const isControlled = valueProp !== undefined\n    const [internal, setInternal] = useState(defaultValue)\n    const value = isControlled ? valueProp : internal\n    const groupId = useId()\n    const containerRef = useRef<HTMLDivElement>(null)\n\n    const [itemValues, setItemValues] = useState<string[]>([])\n    const [focusedValue, setFocusedValue] = useState<string | null>(null)\n\n    const registerItem = useCallback((v: string) => {\n      setItemValues((prev) => (prev.includes(v) ? prev : [...prev, v]))\n    }, [])\n\n    const unregisterItem = useCallback((v: string) => {\n      setItemValues((prev) => prev.filter((x) => x !== v))\n    }, [])\n\n    const setValue = useCallback(\n      (v: string) => {\n        if (!isControlled) setInternal(v)\n        onValueChange?.(v)\n        setFocusedValue(v)\n      },\n      [isControlled, onValueChange],\n    )\n\n    const handleKeyDown = useCallback(\n      (e: KeyboardEvent<HTMLDivElement>) => {\n        const count = itemValues.length\n        if (count === 0) return\n\n        const currentIdx = focusedValue ? itemValues.indexOf(focusedValue) : -1\n\n        let nextIdx = currentIdx\n\n        if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {\n          e.preventDefault()\n          nextIdx = currentIdx < 0 ? 0 : (currentIdx + 1) % count\n        } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {\n          e.preventDefault()\n          nextIdx = currentIdx < 0 ? count - 1 : (currentIdx - 1 + count) % count\n        } else if (e.key === 'Home') {\n          e.preventDefault()\n          nextIdx = 0\n        } else if (e.key === 'End') {\n          e.preventDefault()\n          nextIdx = count - 1\n        }\n\n        if (nextIdx !== currentIdx && nextIdx >= 0) {\n          const nextValue = itemValues[nextIdx]\n          if (nextValue) {\n            setFocusedValue(nextValue)\n            setValue(nextValue)\n            const button = containerRef.current?.querySelector<HTMLElement>(\n              `[data-radio-value=\"${nextValue}\"]`,\n            )\n            button?.focus()\n          }\n        }\n      },\n      [itemValues, focusedValue, setValue],\n    )\n\n    const ctx = useMemo<RadioGroupContextValue>(\n      () => ({\n        value,\n        setValue,\n        name,\n        groupId,\n        focusedValue,\n        setFocusedValue,\n        itemValues,\n        registerItem,\n        unregisterItem,\n        required,\n        ariaInvalid,\n      }),\n      [\n        value,\n        setValue,\n        name,\n        groupId,\n        focusedValue,\n        itemValues,\n        registerItem,\n        unregisterItem,\n        required,\n        ariaInvalid,\n      ],\n    )\n\n    return (\n      <RadioGroupContext.Provider value={ctx}>\n        <div\n          ref={(node) => {\n            ;(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node\n            if (typeof ref === 'function') ref(node)\n            else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node\n          }}\n          role=\"radiogroup\"\n          aria-invalid={ariaInvalid}\n          aria-describedby={ariaDescribedby}\n          onKeyDown={handleKeyDown}\n          className={cn('flex flex-col gap-2', className)}\n          {...props}\n        >\n          {children}\n        </div>\n      </RadioGroupContext.Provider>\n    )\n  },\n)\nRadioGroup.displayName = 'RadioGroup'\n\nexport interface RadioItemProps {\n  value: string\n  label?: string\n  disabled?: boolean\n  className?: string\n}\n\nexport const RadioItem = forwardRef<HTMLDivElement, RadioItemProps>(\n  ({ value: itemValue, label, disabled, className, ...props }, ref) => {\n    const {\n      value,\n      setValue,\n      name,\n      groupId,\n      focusedValue,\n      setFocusedValue,\n      itemValues,\n      registerItem,\n      unregisterItem,\n      required,\n      ariaInvalid,\n    } = useRadioGroupCtx('RadioItem')\n    const reduceMotion = useReducedMotion()\n    const isSelected = value === itemValue\n    const id = `${groupId}-${itemValue}`\n\n    useEffect(() => {\n      if (disabled) return\n      registerItem(itemValue)\n      return () => unregisterItem(itemValue)\n    }, [itemValue, disabled, registerItem, unregisterItem])\n\n    const isTabbable =\n      focusedValue !== null ? focusedValue === itemValue : itemValue === (value ?? itemValues[0])\n\n    return (\n      <div ref={ref} className={cn('flex items-center gap-3 min-h-11', className)} {...props}>\n        <input\n          id={id}\n          type=\"radio\"\n          name={name}\n          value={itemValue}\n          checked={isSelected}\n          disabled={disabled}\n          required={required}\n          readOnly\n          className=\"sr-only\"\n          tabIndex={-1}\n        />\n        <button\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={isSelected}\n          aria-label={label}\n          data-radio-value={itemValue}\n          disabled={disabled}\n          tabIndex={isTabbable ? 0 : -1}\n          onClick={() => {\n            if (!disabled) {\n              playClickSound()\n              setValue(itemValue)\n              setFocusedValue(itemValue)\n            }\n          }}\n          onFocus={() => setFocusedValue(itemValue)}\n          onMouseEnter={() => {\n            if (!disabled) playHoverSound()\n          }}\n          className={cn(\n            'relative flex size-5 shrink-0 items-center justify-center rounded-full border p-0 transition-[color,background-color,border-color,transform]',\n            'active:scale-[0.97] motion-reduce:active:scale-100',\n            'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-bg)',\n            'disabled:cursor-not-allowed disabled:opacity-50',\n            isSelected ? 'border-(--color-fg)' : 'border-(--color-border)',\n            ariaInvalid && 'border-(--color-error)',\n          )}\n          onPointerDown={(e) => e.preventDefault()}\n        >\n          {isSelected && (\n            <motion.span\n              layoutId={`${groupId}-dot`}\n              className=\"rounded-full bg-(--color-fg)\"\n              style={{ width: 8, height: 8 }}\n              transition={reduceMotion ? { duration: 0 } : springs.moderate}\n            />\n          )}\n        </button>\n        {label && (\n          <label\n            htmlFor={id}\n            className=\"min-h-11 flex items-center text-sm text-(--color-fg) select-none\"\n            onClick={() => {\n              if (!disabled) {\n                playClickSound()\n                setValue(itemValue)\n                setFocusedValue(itemValue)\n              }\n            }}\n          >\n            {label}\n          </label>\n        )}\n      </div>\n    )\n  },\n)\nRadioItem.displayName = 'RadioItem'\n\nexport function RadioGroupPreview() {\n  const [value, setValue] = useState('medium')\n  return (\n    <div className=\"flex h-full w-full items-center justify-center p-6\">\n      <RadioGroup value={value} onValueChange={setValue} name=\"size\">\n        <RadioItem value=\"small\" label=\"Small\" />\n        <RadioItem value=\"medium\" label=\"Medium\" />\n        <RadioItem value=\"large\" label=\"Large\" />\n      </RadioGroup>\n    </div>\n  )\n}\n"
    },
    {
      "path": "lib/sound.ts",
      "type": "registry:lib",
      "content": "'use client'\n\nconst MUTE_KEY = 'sound-muted'\nconst MUTE_EVENT = 'sound-mute-change'\n\nlet muted = false\nif (typeof window !== 'undefined') {\n  try {\n    muted = localStorage.getItem(MUTE_KEY) === '1'\n  } catch {}\n}\n\nexport function isSoundMuted(): boolean {\n  return muted\n}\n\nexport function setSoundMuted(value: boolean) {\n  muted = value\n  if (typeof window !== 'undefined') {\n    try {\n      localStorage.setItem(MUTE_KEY, value ? '1' : '0')\n    } catch {}\n    window.dispatchEvent(new CustomEvent(MUTE_EVENT))\n  }\n}\n\nexport function toggleSoundMuted(): boolean {\n  setSoundMuted(!muted)\n  return muted\n}\n\nexport function subscribeSoundMuted(callback: () => void) {\n  if (typeof window === 'undefined') return () => {}\n  window.addEventListener(MUTE_EVENT, callback)\n  const onStorage = (e: StorageEvent) => {\n    if (e.key === MUTE_KEY) callback()\n  }\n  window.addEventListener('storage', onStorage)\n  return () => {\n    window.removeEventListener(MUTE_EVENT, callback)\n    window.removeEventListener('storage', onStorage)\n  }\n}\n\nlet audioCtx: AudioContext | null = null\n\nfunction initAudio(): AudioContext | null {\n  if (typeof window === 'undefined') return null\n  if (!audioCtx) {\n    const AudioContextClass =\n      window.AudioContext ||\n      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext\n    if (AudioContextClass) {\n      audioCtx = new AudioContextClass()\n    }\n  }\n  if (audioCtx && audioCtx.state === 'suspended') {\n    audioCtx.resume().catch(() => {})\n  }\n  return audioCtx\n}\n\nif (typeof window !== 'undefined') {\n  const unlock = () => {\n    initAudio()\n    window.removeEventListener('pointerdown', unlock)\n    window.removeEventListener('keydown', unlock)\n    window.removeEventListener('touchstart', unlock)\n  }\n  window.addEventListener('pointerdown', unlock, { passive: true })\n  window.addEventListener('keydown', unlock, { passive: true })\n  window.addEventListener('touchstart', unlock, { passive: true })\n}\n\nexport function getAudioContext(): AudioContext | null {\n  return initAudio()\n}\n\nexport function playHoverSound(volume = 0.12, pitch = 1.2) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1400 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(350 * pitch, now + 0.012)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.012)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.012)\n  } catch {}\n}\n\nexport function playClickSound(volume = 0.2, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'triangle'\n    osc.frequency.setValueAtTime(850 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(160 * pitch, now + 0.02)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.02)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.02)\n  } catch {}\n}\n\nexport function playTickSound(volume = 0.1, pitch = 1.5) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(1800 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(450 * pitch, now + 0.01)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.01)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.01)\n  } catch {}\n}\n\nexport function playBounceSound(volume = 0.3, pitch = 1.0) {\n  if (muted) return\n  try {\n    const ctx = getAudioContext()\n    if (!ctx) return\n    const now = ctx.currentTime\n\n    const osc = ctx.createOscillator()\n    const gain = ctx.createGain()\n\n    osc.type = 'sine'\n    osc.frequency.setValueAtTime(260 * pitch, now)\n    osc.frequency.exponentialRampToValueAtTime(650 * pitch, now + 0.04)\n    osc.frequency.exponentialRampToValueAtTime(190 * pitch, now + 0.14)\n\n    gain.gain.setValueAtTime(volume, now)\n    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.14)\n\n    osc.connect(gain)\n    gain.connect(ctx.destination)\n\n    osc.start(now)\n    osc.stop(now + 0.14)\n\n    const subOsc = ctx.createOscillator()\n    const subGain = ctx.createGain()\n\n    subOsc.type = 'triangle'\n    subOsc.frequency.setValueAtTime(420 * pitch, now)\n    subOsc.frequency.exponentialRampToValueAtTime(120 * pitch, now + 0.05)\n\n    subGain.gain.setValueAtTime(volume * 0.7, now)\n    subGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05)\n\n    subOsc.connect(subGain)\n    subGain.connect(ctx.destination)\n\n    subOsc.start(now)\n    subOsc.stop(now + 0.05)\n  } catch {}\n}\n"
    }
  ],
  "type": "registry:ui"
}
