{"slug":"switch","name":"Switch","description":"A weighty thumb that squishes and stretches.","group":"Forms","detail_url":"https://beui.dev/native/r/switch","page_url":"https://beui.dev/react-native/switch","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/switch?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/switch","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/switch/index.ts","content":"export type { SwitchProps } from \"./switch\";\nexport { Switch } from \"./switch\";\n","type":"component"},{"path":"src/components/switch/switch.tsx","content":"import { type Ref, useEffect, useState } from \"react\";\nimport {\n  I18nManager,\n  Platform,\n  Pressable,\n  type PressableProps,\n  StyleSheet,\n  Text,\n  type View,\n} from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  interpolateColor,\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\n  withSequence,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\n\n// Heavy, deliberate thumb: high mass keeps the travel weighty without wobble.\nconst THUMB_SPRING = {\n  stiffness: 800,\n  damping: 80,\n  mass: 4,\n  reduceMotion: ReduceMotion.System,\n} as const;\nconst TRACK_FADE = { duration: 200, reduceMotion: ReduceMotion.System };\n\nconst TRACK_WIDTH = 48;\nconst TRACK_HEIGHT = 28;\nconst PADDING = 4;\nconst THUMB = 20;\nconst STRETCH = 4;\nconst TRAVEL = TRACK_WIDTH - PADDING * 2 - THUMB;\n// Tops the 28-point track up to a 48-point touch area.\nconst HIT_SLOP = { top: 10, bottom: 10, left: 0, right: 0 };\n\nexport type SwitchProps = Omit<\n  PressableProps,\n  \"children\" | \"style\" | \"onPress\" | \"disabled\"\n> & {\n  /** Controlled state. Omit it and use `defaultChecked` for uncontrolled use. */\n  checked?: boolean;\n  defaultChecked?: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n  disabled?: boolean;\n  /** Visible label beside the switch. Pressing it toggles too. */\n  label?: string;\n  /** Disables thumb motion independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  ref?: Ref<View>;\n};\n\nexport function Switch({\n  checked: checkedProp,\n  defaultChecked = false,\n  onCheckedChange,\n  disabled = false,\n  label,\n  disableAnimation = false,\n  className = \"\",\n  accessibilityLabel,\n  accessibilityState,\n  hitSlop,\n  onPressIn,\n  onPressOut,\n  ref,\n  ...props\n}: SwitchProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultChecked);\n  const checked = checkedProp ?? uncontrolled;\n  const reducedMotion = useReducedMotionPreference();\n  const animate = !reducedMotion && !disableAnimation;\n  const direction = I18nManager.isRTL ? -1 : 1;\n\n  const { backgroundColor: onColor } = useResolveClassNames(\"bg-primary\");\n  const { backgroundColor: offColor } = useResolveClassNames(\"bg-muted/60\");\n\n  const position = useSharedValue(checked ? 1 : 0);\n  const track = useSharedValue(checked ? 1 : 0);\n  const press = useSharedValue(0);\n  const shake = useSharedValue(0);\n\n  useEffect(() => {\n    const target = checked ? 1 : 0;\n    position.value = animate ? withSpring(target, THUMB_SPRING) : target;\n    track.value = animate ? withTiming(target, TRACK_FADE) : target;\n  }, [animate, checked, position, track]);\n\n  useEffect(() => {\n    if (!animate || disabled) {\n      cancelAnimation(press);\n      press.value = 0;\n    }\n  }, [animate, disabled, press]);\n\n  const trackStyle = useAnimatedStyle(() => ({\n    backgroundColor: interpolateColor(\n      track.value,\n      [0, 1],\n      [String(offColor ?? \"gray\"), String(onColor ?? \"blue\")],\n    ),\n  }));\n\n  const thumbStyle = useAnimatedStyle(() => {\n    const stretch = STRETCH * press.value;\n    // Grow toward the destination: leftward from the \"on\" end, rightward from \"off\".\n    const lead = checked ? -stretch : 0;\n    return {\n      width: THUMB + stretch,\n      transform: [\n        {\n          translateX:\n            direction * (position.value * TRAVEL + lead) + shake.value,\n        },\n        { scale: 1 - 0.1 * press.value },\n      ],\n    };\n  }, [checked, direction]);\n\n  function toggle() {\n    const next = !checked;\n    if (checkedProp === undefined) setUncontrolled(next);\n    onCheckedChange?.(next);\n  }\n\n  return (\n    <Pressable\n      {...props}\n      ref={ref}\n      accessible\n      accessibilityRole=\"switch\"\n      accessibilityLabel={accessibilityLabel ?? label}\n      accessibilityState={{ ...accessibilityState, checked, disabled }}\n      aria-checked={checked}\n      aria-disabled={disabled}\n      // Native keeps presses alive while disabled so the thumb can shake; web needs the\n      // real prop because react-native-web derives aria-disabled from it.\n      disabled={Platform.OS === \"web\" && disabled}\n      hitSlop={hitSlop ?? HIT_SLOP}\n      className={`self-start flex-row items-center gap-3 ${disabled ? \"opacity-60\" : \"\"} ${className}`}\n      onPress={() => {\n        if (!disabled) toggle();\n      }}\n      onPressIn={(event) => {\n        if (disabled) {\n          if (animate) {\n            shake.value = withDelay(\n              200,\n              withSequence(\n                withTiming(-2, { duration: 150 }),\n                withTiming(2, { duration: 150 }),\n                withTiming(-1, { duration: 150 }),\n                withTiming(0, { duration: 150 }),\n              ),\n            );\n          }\n        } else if (animate) {\n          press.value = withSpring(1, THUMB_SPRING);\n        }\n        onPressIn?.(event);\n      }}\n      onPressOut={(event) => {\n        if (animate) press.value = withSpring(0, THUMB_SPRING);\n        onPressOut?.(event);\n      }}\n    >\n      <Animated.View style={[styles.track, trackStyle]}>\n        <Animated.View\n          className=\"bg-background\"\n          style={[styles.thumb, thumbStyle]}\n        />\n      </Animated.View>\n      {label ? (\n        <Text className=\"shrink text-sm text-foreground\">{label}</Text>\n      ) : null}\n    </Pressable>\n  );\n}\n\nconst styles = StyleSheet.create({\n  track: {\n    width: TRACK_WIDTH,\n    height: TRACK_HEIGHT,\n    borderRadius: TRACK_HEIGHT / 2,\n    flexShrink: 0,\n    justifyContent: \"center\",\n  },\n  thumb: {\n    position: \"absolute\",\n    start: PADDING,\n    height: THUMB,\n    borderRadius: THUMB / 2,\n    boxShadow: \"0 2px 4px rgba(0, 0, 0, 0.18)\",\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/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"}]}