{"slug":"radio","name":"Radio Group","description":"One dot glides to the option you pick.","group":"Forms","detail_url":"https://beui.dev/native/r/radio","page_url":"https://beui.dev/react-native/radio","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/radio?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/radio","libraries":["react","react-native","react-native-reanimated","uniwind"],"install":["npx expo install react-native-reanimated react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/radio/index.ts","content":"export type { RadioGroupItemProps, RadioGroupProps } from \"./radio\";\nexport { RadioGroup, RadioGroupItem } from \"./radio\";\n","type":"component"},{"path":"src/components/radio/radio.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  interpolateColor,\n  type SharedValue,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { motion } from \"../../styles/motion\";\n\nconst RING = 20;\nconst BORDER = 2;\nconst DOT = 8;\nconst DOT_INSET = (RING - DOT) / 2;\n// Tops a 20-point row up to a 48-point touch area.\nconst HIT_SLOP = { top: 14, bottom: 14, left: 0, right: 0 };\nconst COLOR_FADE = { duration: 200, reduceMotion: motion.fade.reduceMotion };\n\ntype Measure = () => void;\n\ntype RadioGroupContextValue = {\n  value: string;\n  select: (value: string) => void;\n  animate: boolean;\n  groupRef: RefObject<View | null>;\n  /** Records where an item's ring sits inside the group. */\n  place: (value: string, x: number, y: number) => void;\n  register: (value: string, measure: Measure) => () => void;\n  dotScale: SharedValue<number>;\n};\n\nconst RadioGroupContext = createContext<RadioGroupContextValue | null>(null);\n\nfunction useRadioGroup() {\n  const context = useContext(RadioGroupContext);\n  if (!context) {\n    throw new Error(\"RadioGroupItem must be rendered inside a RadioGroup.\");\n  }\n  return context;\n}\n\nexport type RadioGroupProps = Omit<ViewProps, \"children\"> & {\n  /** Controlled value. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Disables dot and press motion independently of the system preference. */\n  disableAnimation?: boolean;\n  children: ReactNode;\n  className?: string;\n};\n\nexport function RadioGroup({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  orientation = \"vertical\",\n  disableAnimation = false,\n  children,\n  className = \"\",\n  onLayout,\n  ...props\n}: RadioGroupProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const value = valueProp ?? uncontrolled;\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const { backgroundColor: dotColor } = useResolveClassNames(\"bg-primary\");\n\n  const groupRef = useRef<View>(null);\n  const positions = useRef(new Map<string, { x: number; y: number }>());\n  const measures = useRef(new Map<string, Measure>());\n  const valueRef = useRef(value);\n  valueRef.current = value;\n  const animateRef = useRef(animate);\n  animateRef.current = animate;\n\n  const x = useSharedValue(0);\n  const y = useSharedValue(0);\n  const visible = useSharedValue(0);\n  const dotScale = useSharedValue(1);\n\n  // Glide to the selected ring, or appear in place when nothing was shown yet.\n  const moveDot = useCallback(\n    (target: string) => {\n      const position = positions.current.get(target);\n      if (!position) {\n        visible.value = 0;\n        return;\n      }\n      const left = position.x + DOT_INSET;\n      const top = position.y + DOT_INSET;\n      if (visible.value === 0 || !animateRef.current) {\n        x.value = left;\n        y.value = top;\n      } else {\n        x.value = withSpring(left, motion.layout);\n        y.value = withSpring(top, motion.layout);\n      }\n      visible.value = 1;\n    },\n    [visible, x, y],\n  );\n\n  useEffect(() => {\n    moveDot(value);\n  }, [moveDot, value]);\n\n  // Stable so items only re-register when their own value changes.\n  const place = useCallback(\n    (item: string, left: number, top: number) => {\n      positions.current.set(item, { x: left, y: top });\n      if (item === valueRef.current) moveDot(item);\n    },\n    [moveDot],\n  );\n\n  const register = useCallback(\n    (item: string, measure: Measure) => {\n      measures.current.set(item, measure);\n      return () => {\n        measures.current.delete(item);\n        positions.current.delete(item);\n        if (item === valueRef.current) visible.value = 0;\n      };\n    },\n    [visible],\n  );\n\n  const context = useMemo<RadioGroupContextValue>(\n    () => ({\n      value,\n      animate,\n      groupRef,\n      dotScale,\n      place,\n      register,\n      select: (next) => {\n        if (valueProp === undefined) setUncontrolled(next);\n        onValueChange?.(next);\n      },\n    }),\n    [animate, dotScale, onValueChange, place, register, value, valueProp],\n  );\n\n  const dotStyle = useAnimatedStyle(() => ({\n    opacity: visible.value,\n    transform: [\n      { translateX: x.value },\n      { translateY: y.value },\n      { scale: dotScale.value },\n    ],\n  }));\n\n  return (\n    <RadioGroupContext.Provider value={context}>\n      <View\n        {...props}\n        ref={groupRef}\n        accessibilityRole=\"radiogroup\"\n        className={`relative gap-3 ${orientation === \"vertical\" ? \"flex-col\" : \"flex-row flex-wrap\"} ${className}`}\n        onLayout={(event) => {\n          // Items can move without their own layout changing, such as on reflow.\n          for (const measure of measures.current.values()) measure();\n          onLayout?.(event);\n        }}\n      >\n        {children}\n        <Animated.View\n          style={[styles.dot, { backgroundColor: dotColor }, dotStyle]}\n        />\n      </View>\n    </RadioGroupContext.Provider>\n  );\n}\n\nexport type RadioGroupItemProps = Omit<\n  PressableProps,\n  \"children\" | \"style\" | \"onPress\" | \"disabled\"\n> & {\n  value: string;\n  label?: string;\n  /** Supporting text under the label, read as the hint rather than the name. */\n  description?: string;\n  disabled?: boolean;\n  className?: string;\n  ref?: Ref<View>;\n};\n\nexport function RadioGroupItem({\n  value,\n  label,\n  description,\n  disabled = false,\n  className = \"\",\n  accessibilityLabel,\n  accessibilityHint,\n  accessibilityState,\n  hitSlop,\n  onPressIn,\n  onPressOut,\n  ref,\n  ...props\n}: RadioGroupItemProps) {\n  const group = useRadioGroup();\n  const { animate, dotScale, groupRef, place, register } = group;\n  const selected = group.value === value;\n  const ringRef = useRef<View>(null);\n\n  const { backgroundColor: idleBorder } = useResolveClassNames(\"bg-muted/50\");\n  const { backgroundColor: activeBorder } = useResolveClassNames(\"bg-primary\");\n\n  const active = useSharedValue(selected ? 1 : 0);\n  const scale = useSharedValue(1);\n\n  const measure = useCallback(() => {\n    const ring = ringRef.current;\n    const groupNode = groupRef.current;\n    if (!ring || !groupNode) return;\n    ring.measureLayout(groupNode, (left, top) => place(value, left, top));\n  }, [groupRef, place, value]);\n\n  useEffect(() => register(value, measure), [measure, register, value]);\n\n  useEffect(() => {\n    const target = selected ? 1 : 0;\n    active.value = animate ? withTiming(target, COLOR_FADE) : target;\n  }, [active, animate, selected]);\n\n  useEffect(() => {\n    if (!animate || disabled) {\n      cancelAnimation(scale);\n      scale.value = 1;\n    }\n  }, [animate, disabled, scale]);\n\n  const ringStyle = useAnimatedStyle(() => ({\n    borderColor: interpolateColor(\n      active.value,\n      [0, 1],\n      [String(idleBorder ?? \"gray\"), String(activeBorder ?? \"blue\")],\n    ),\n    transform: [{ scale: scale.value }],\n  }));\n\n  return (\n    <Pressable\n      {...props}\n      ref={ref}\n      accessible\n      accessibilityRole=\"radio\"\n      accessibilityLabel={accessibilityLabel ?? label}\n      accessibilityHint={accessibilityHint ?? description}\n      accessibilityState={{\n        ...accessibilityState,\n        checked: selected,\n        disabled,\n      }}\n      aria-checked={selected}\n      aria-disabled={disabled}\n      disabled={disabled}\n      hitSlop={hitSlop ?? HIT_SLOP}\n      className={`self-start flex-row gap-3 ${description ? \"items-start\" : \"items-center\"} ${className}`}\n      onPress={() => {\n        if (!selected) group.select(value);\n      }}\n      onPressIn={(event) => {\n        if (animate) {\n          scale.value = withSpring(0.92, motion.press);\n          // The dot lives in the group, so it presses along with its ring.\n          if (selected) dotScale.value = withSpring(0.92, motion.press);\n        }\n        onPressIn?.(event);\n      }}\n      onPressOut={(event) => {\n        if (animate) {\n          scale.value = withSpring(1, motion.press);\n          dotScale.value = withSpring(1, motion.press);\n        }\n        onPressOut?.(event);\n      }}\n    >\n      <Animated.View\n        ref={ringRef}\n        onLayout={measure}\n        style={[styles.ring, disabled && styles.disabled, ringStyle]}\n      />\n      {label || description ? (\n        <View className={`shrink gap-0.5 ${disabled ? \"opacity-60\" : \"\"}`}>\n          {label ? (\n            <Text className=\"text-sm leading-5 text-foreground\">{label}</Text>\n          ) : null}\n          {description ? (\n            <Text className=\"text-xs text-muted\">{description}</Text>\n          ) : null}\n        </View>\n      ) : null}\n    </Pressable>\n  );\n}\n\nconst styles = StyleSheet.create({\n  ring: {\n    width: RING,\n    height: RING,\n    borderRadius: RING / 2,\n    borderWidth: BORDER,\n    flexShrink: 0,\n  },\n  disabled: { opacity: 0.6 },\n  dot: {\n    position: \"absolute\",\n    left: 0,\n    top: 0,\n    width: DOT,\n    height: DOT,\n    borderRadius: DOT / 2,\n    pointerEvents: \"none\",\n  },\n});\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n}\n","type":"theme"}]}