{"slug":"radio","name":"Radio Group","description":"Single-select choice control with a gliding layoutId indicator dot and spring press feedback.","category":"motion","source_url":"https://beui.dev/r/radio/raw","detail_url":"https://beui.dev/r/radio","raw_url":"https://beui.dev/r/radio/raw","page_url":"https://beui.dev/components/motion/radio","markdown_url":"https://beui.dev/components/motion/radio.md","published_at":"2026-06-23","updated_at":"2026-07-13","dependencies":["clsx","motion","react","tailwind-merge"],"internal":["@/components/motion/radio","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/radio.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/radio\n\nimport { motion, MotionConfig, useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype RadioCtx = {\n  value: string;\n  setValue: (value: string) => void;\n  layoutId: string;\n};\n\nconst RadioCtx = createContext<RadioCtx | null>(null);\n\nfunction useRadioGroup() {\n  const ctx = useContext(RadioCtx);\n  if (!ctx) {\n    throw new Error(\"RadioGroupItem must be used inside <RadioGroup>\");\n  }\n  return ctx;\n}\n\nexport interface RadioGroupProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  children: ReactNode;\n  className?: string;\n  orientation?: \"vertical\" | \"horizontal\";\n}\n\nexport function RadioGroup({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  children,\n  className,\n  orientation = \"vertical\",\n}: RadioGroupProps) {\n  const [internal, setInternal] = useState(defaultValue);\n  const layoutId = useId();\n  const reduce = useReducedMotion();\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const setValue = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n    },\n    [controlled, onValueChange],\n  );\n  const contextValue = useMemo(\n    () => ({ value: current, setValue, layoutId }),\n    [current, layoutId, setValue],\n  );\n\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : SPRING_LAYOUT}>\n      <RadioCtx.Provider value={contextValue}>\n        <div\n          role=\"radiogroup\"\n          className={cn(\n            \"flex gap-3\",\n            orientation === \"vertical\" ? \"flex-col\" : \"flex-row flex-wrap\",\n            className,\n          )}\n        >\n          {children}\n        </div>\n      </RadioCtx.Provider>\n    </MotionConfig>\n  );\n}\n\nexport interface RadioGroupItemProps {\n  value: string;\n  label?: string;\n  disabled?: boolean;\n  className?: string;\n  id?: string;\n}\n\nexport function RadioGroupItem({\n  value,\n  label,\n  disabled,\n  className,\n  id: idProp,\n}: RadioGroupItemProps) {\n  const { value: groupValue, setValue, layoutId } = useRadioGroup();\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const selected = groupValue === value;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"radio\"\n        aria-checked={selected}\n        disabled={disabled}\n        onClick={() => !disabled && setValue(value)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={selected ? \"checked\" : \"unchecked\"}\n        className={cn(\n          \"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 outline-none transition-colors duration-200\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          selected\n            ? \"border-primary\"\n            : \"border-muted-foreground/50 hover:border-muted-foreground\",\n        )}\n      >\n        {selected ? (\n          <motion.span\n            layoutId={layoutId}\n            className=\"absolute inset-1 rounded-full bg-primary\"\n            transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n          />\n        ) : null}\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/radio.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/motion/radio\";\n\nexport function RadioPreview() {\n  const [plan, setPlan] = useState(\"pro\");\n\n  return (\n    <RadioGroup value={plan} onValueChange={setPlan} className=\"min-w-48\">\n      <RadioGroupItem value=\"starter\" label=\"Starter — free\" />\n      <RadioGroupItem value=\"pro\" label=\"Pro — $12/mo\" />\n      <RadioGroupItem value=\"team\" label=\"Team — $29/mo\" />\n      <RadioGroupItem value=\"legacy\" label=\"Legacy plan\" disabled />\n    </RadioGroup>\n  );\n}\n"}]}