{"slug":"range-slider","name":"Range Slider","description":"A bar thumb that glides between steps.","group":"Forms","detail_url":"https://beui.dev/native/r/range-slider","page_url":"https://beui.dev/react-native/range-slider","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/range-slider?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/range-slider","libraries":["react","react-native","react-native-gesture-handler","react-native-reanimated","react-native-worklets"],"install":["npx expo install react-native-gesture-handler 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/range-slider/index.ts","content":"export type { RangeSliderProps } from \"./range-slider\";\nexport { RangeSlider, snapSliderValue } from \"./range-slider\";\n","type":"component"},{"path":"src/components/range-slider/range-slider.tsx","content":"import { type Ref, useCallback, useEffect, useState } from \"react\";\nimport {\n  type AccessibilityActionEvent,\n  Platform,\n  StyleSheet,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport { Gesture, GestureDetector } from \"react-native-gesture-handler\";\nimport Animated, {\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport { SPRING_GLIDE } from \"../../styles/motion\";\n\n// Bouncy grab feedback for the thumb's stretch only.\nconst SPRING_BOUNCY = { stiffness: 500, damping: 14, mass: 0.7 };\nconst GRAB_STRETCH = 1.35;\n// The 4-point thumb starts 8 points inside the track and ends 12 points short\n// of the far edge, so it always sits inside the fill's rounded end.\nconst THUMB_START = 8;\nconst THUMB_INSET = 20;\n// The fill sits in a clip inset 2 points from each side.\nconst FILL_INSET = 2;\nconst MAX_TICKS = 50;\n\nconst clamp = (value: number, lo: number, hi: number) => {\n  \"worklet\";\n  return Math.min(hi, Math.max(lo, value));\n};\n\n/**\n * Nearest legal value on [min, max] for the given step. Max counts as a\n * candidate when the step doesn't divide the range, so a finger near the end\n * doesn't snap back onto the last whole step.\n */\nexport function snapSliderValue(\n  next: number,\n  min: number,\n  max: number,\n  step: number,\n) {\n  \"worklet\";\n  if (!(max > min)) return min;\n  if (!(step > 0)) return clamp(next, min, max);\n  const whole = Math.floor(Number(((max - min) / step).toFixed(6)));\n  const lastWhole = Number((min + whole * step).toFixed(6));\n  const toGrid = clamp(\n    Math.round((next - min) / step) * step + min,\n    min,\n    lastWhole,\n  );\n  const snapped =\n    lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid)\n      ? max\n      : toGrid;\n  return Number(snapped.toFixed(6));\n}\n\nexport type RangeSliderProps = Omit<ViewProps, \"children\"> & {\n  /** Controlled value. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: number;\n  defaultValue?: number;\n  /** Called once per step while dragging, and on every other change. */\n  onValueChange?: (value: number) => void;\n  /** Called when a drag or tap lets go, with the final value. */\n  onSlidingComplete?: (value: number) => void;\n  /** Defaults to 0. */\n  min?: number;\n  /** Defaults to 100. */\n  max?: number;\n  /** Defaults to 1. */\n  step?: number;\n  disabled?: boolean;\n  /** Draws a dot at each step, up to 50 of them. Defaults to true. */\n  showTicks?: boolean;\n  /**\n   * Read instead of the raw number. Pass one when the value carries a unit,\n   * such as \"72.5 kg\" or \"35%\".\n   */\n  formatValueText?: (value: number) => string;\n  /** Moves without springing, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n  ref?: Ref<View>;\n};\n\n/**\n * A stepped slider: drag or tap anywhere on the track. The bar thumb glides\n * between steps and stretches while held.\n */\nexport function RangeSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  onSlidingComplete,\n  min = 0,\n  max = 100,\n  step = 1,\n  disabled = false,\n  showTicks = true,\n  formatValueText,\n  disableAnimation = false,\n  className = \"\",\n  style,\n  accessibilityLabel,\n  ref,\n  ...props\n}: RangeSliderProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [internal, setInternal] = useState(defaultValue);\n  const [width, setWidth] = useState(0);\n\n  // Collapse inverted or empty ranges and non-positive steps, so the maths\n  // below never divides by zero or walks a NaN grid.\n  const lo = min;\n  const hi = max > min ? max : min;\n  const stride = step > 0 ? step : 1;\n  const controlled = value !== undefined;\n  const current = clamp(controlled ? value : internal, lo, hi);\n  const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;\n\n  const trackWidth = useSharedValue(0);\n  const target = useSharedValue(percent);\n  // The last value the gesture reported, so each step notifies only once.\n  const reported = useSharedValue(current);\n  const grabbed = useSharedValue(0);\n\n  useEffect(() => {\n    target.value = percent;\n    reported.value = current;\n  }, [current, percent, reported, target]);\n\n  const commit = useCallback(\n    (next: number) => {\n      const clean = snapSliderValue(next, lo, hi, stride);\n      if (!controlled) setInternal(clean);\n      onValueChange?.(clean);\n    },\n    [controlled, hi, lo, onValueChange, stride],\n  );\n  const complete = useCallback(\n    (next: number) => onSlidingComplete?.(next),\n    [onSlidingComplete],\n  );\n\n  // Maps a touch to a value along the thumb's own path, so the thumb lands\n  // under the finger rather than under the track's raw proportion.\n  const moveTo = (x: number) => {\n    \"worklet\";\n    const span = trackWidth.value - THUMB_INSET;\n    if (span <= 0) return;\n    const ratio = clamp((x - THUMB_START - 2) / span, 0, 1);\n    const next = snapSliderValue(lo + ratio * (hi - lo), lo, hi, stride);\n    if (next === reported.value) return;\n    reported.value = next;\n    target.value = hi > lo ? ((next - lo) / (hi - lo)) * 100 : 0;\n    scheduleOnRN(commit, next);\n  };\n\n  const pan = Gesture.Pan()\n    .enabled(!disabled)\n    // Horizontal drags own the slider; vertical ones go on to scroll the page.\n    .activeOffsetX([-4, 4])\n    .failOffsetY([-12, 12])\n    .hitSlop({ vertical: 4 })\n    .onStart((event) => {\n      grabbed.value = 1;\n      moveTo(event.x);\n    })\n    .onUpdate((event) => {\n      moveTo(event.x);\n    })\n    .onFinalize((_, success) => {\n      grabbed.value = 0;\n      if (success) scheduleOnRN(complete, reported.value);\n    });\n  const tap = Gesture.Tap()\n    .enabled(!disabled)\n    // A finger that travels is a drag or a scroll, not a tap; failing early\n    // hands a vertical swipe back to the page.\n    .maxDistance(10)\n    .hitSlop({ vertical: 4 })\n    .onBegin(() => {\n      grabbed.value = 1;\n    })\n    .onEnd((event) => {\n      moveTo(event.x);\n      scheduleOnRN(complete, reported.value);\n    })\n    .onFinalize(() => {\n      grabbed.value = 0;\n    });\n  const gesture = Gesture.Race(pan, tap);\n\n  const position = useDerivedValue(() =>\n    reduce ? target.value : withSpring(target.value, SPRING_GLIDE),\n  );\n\n  const thumbStyle = useAnimatedStyle(() => {\n    const span = Math.max(0, trackWidth.value - THUMB_INSET);\n    return {\n      transform: [\n        { translateX: THUMB_START + (span * position.value) / 100 },\n        {\n          scaleY: reduce\n            ? 1\n            : withSpring(grabbed.value ? GRAB_STRETCH : 1, SPRING_BOUNCY),\n        },\n      ],\n    };\n  });\n  // A full-size fill slides in from the left, so its rounded end never\n  // stretches. Its edge runs a few points past the thumb at every position.\n  const fillStyle = useAnimatedStyle(() => {\n    const p = position.value;\n    const clip = trackWidth.value - FILL_INSET * 2;\n    return {\n      transform: [\n        {\n          translateX: p >= 100 ? 0 : ((p - 100) / 100) * clip + 14 - 0.16 * p,\n        },\n      ],\n    };\n  });\n\n  // Floor rather than round, so a range the step doesn't divide stops its dots\n  // at the last whole step. toFixed first, because 0.3 / 0.1 is 2.999….\n  const steps = Math.floor(Number(((hi - lo) / stride).toFixed(6)));\n  const ticks =\n    showTicks && steps > 0 && steps <= MAX_TICKS && width > 0\n      ? Array.from({ length: steps + 1 }, (_, i) =>\n          Number((lo + i * stride).toFixed(6)),\n        )\n      : [];\n\n  const adjust = (event: AccessibilityActionEvent) => {\n    if (disabled) return;\n    const delta = event.nativeEvent.actionName === \"increment\" ? 1 : -1;\n    commit(current + delta * stride);\n  };\n  // Arrow keys, Page Up and Down, Home and End on web, like the web slider.\n  const webKeys =\n    Platform.OS === \"web\" && !disabled\n      ? {\n          focusable: true,\n          onKeyDown: (event: { key: string; preventDefault: () => void }) => {\n            const map: Record<string, number> = {\n              ArrowRight: current + stride,\n              ArrowUp: current + stride,\n              ArrowLeft: current - stride,\n              ArrowDown: current - stride,\n              PageUp: current + stride * 10,\n              PageDown: current - stride * 10,\n              Home: lo,\n              End: hi,\n            };\n            const next = map[event.key];\n            if (next !== undefined) {\n              event.preventDefault();\n              commit(next);\n            }\n          },\n        }\n      : {};\n\n  return (\n    <GestureDetector gesture={gesture}>\n      <View\n        {...props}\n        {...webKeys}\n        ref={ref}\n        accessible\n        accessibilityRole=\"adjustable\"\n        accessibilityLabel={accessibilityLabel}\n        // The aria props reach every platform, including React Native Web,\n        // which ignores accessibilityValue.\n        aria-disabled={disabled}\n        aria-valuemin={lo}\n        aria-valuemax={hi}\n        aria-valuenow={current}\n        aria-valuetext={formatValueText?.(current)}\n        accessibilityActions={[{ name: \"increment\" }, { name: \"decrement\" }]}\n        onAccessibilityAction={adjust}\n        onLayout={(event) => {\n          const next = event.nativeEvent.layout.width;\n          trackWidth.value = next;\n          setWidth(next);\n          props.onLayout?.(event);\n        }}\n        className={`h-10 w-full justify-center overflow-hidden rounded-lg bg-surface ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n        style={style}\n      >\n        <View style={styles.fillClip} className=\"rounded-lg\">\n          <Animated.View\n            style={[StyleSheet.absoluteFill, fillStyle]}\n            className=\"rounded-lg bg-foreground/15\"\n          />\n        </View>\n\n        {ticks.map((tick) => {\n          const share = (tick - lo) / (hi - lo);\n          return (\n            <View\n              key={tick}\n              style={[\n                styles.tick,\n                { left: THUMB_INSET / 2 + share * (width - THUMB_INSET) - 2 },\n              ]}\n              className=\"bg-foreground/25\"\n            />\n          );\n        })}\n\n        <Animated.View\n          style={[styles.thumb, thumbStyle]}\n          className=\"bg-foreground\"\n        />\n      </View>\n    </GestureDetector>\n  );\n}\n\nconst styles = StyleSheet.create({\n  fillClip: {\n    position: \"absolute\",\n    top: 0,\n    bottom: 0,\n    left: FILL_INSET,\n    right: FILL_INSET,\n    overflow: \"hidden\",\n    pointerEvents: \"none\",\n  },\n  tick: {\n    position: \"absolute\",\n    width: 4,\n    height: 4,\n    borderRadius: 2,\n    pointerEvents: \"none\",\n  },\n  thumb: {\n    position: \"absolute\",\n    left: 0,\n    width: 4,\n    height: 24,\n    borderRadius: 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"}]}