Skip to content
pixelfor Expo
Ctrl K

Slider

Inline range slider

A tiny slider with a handle that makes room for its labels

Try it on your phone

Inline range slider

Scan to open Inline range slider in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

range-slider-inline.tsx

import { useCallback, useEffect, useMemo, useState } from "react"
import {
  StyleSheet,
  Text,
  TextInput,
  View,
  type StyleProp,
  type TextInputProps,
  type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
  cancelAnimation,
  ReduceMotion,
  useAnimatedProps,
  useAnimatedStyle,
  useDerivedValue,
  useReducedMotion,
  useSharedValue,
  withSpring,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
type Stop = { value: number; x: number }
type NativeReadoutProps = TextInputProps & { text?: string }
const Readout = Animated.createAnimatedComponent(TextInput)
const GRAB = { stiffness: 500, damping: 14, mass: 0.7 }
const GLIDE = { stiffness: 700, damping: 50, mass: 0.5 }
function clamp(value: number, min: number, max: number) {
  "worklet"
  return Math.min(max, Math.max(min, value))
}
function mapStops(
  stops: Stop[],
  point: number,
  from: keyof Stop,
  to: keyof Stop
) {
  "worklet"
  if (point <= stops[0][from]) return stops[0][to]
  for (let i = 1; i < stops.length; i++) {
    if (point <= stops[i][from]) {
      const lower = stops[i - 1]
      const upper = stops[i]
      return (
        lower[to] +
        ((point - lower[from]) / (upper[from] - lower[from])) *
          (upper[to] - lower[to])
      )
    }
  }
  return stops[stops.length - 1][to]
}
function closestStop(stops: Stop[], x: number) {
  "worklet"
  let nearest = stops[0]
  for (let i = 1; i < stops.length; i++) {
    if (Math.abs(stops[i].x - x) < Math.abs(nearest.x - x)) nearest = stops[i]
  }
  return nearest
}
function defaultFormat(value: number) {
  "worklet"
  return String(value)
}
export interface InlineSliderProps {
  label: string
  min?: number
  max?: number
  step?: number
  value?: number
  defaultValue?: number
  onValueChange?: (value: number) => void
  format?: (value: number) => string
  disabled?: boolean
  showTicks?: boolean
  accessibilityLabel?: string
  formatValueText?: (value: number) => string
  backgroundColor?: string
  foregroundColor?: string
  fillColor?: string
  tickColor?: string
  height?: number
  radius?: number
  textInset?: number
  labelFontSize?: number
  valueFontSize?: number
  reduceMotion?: boolean
  style?: StyleProp<ViewStyle>
  testID?: string
}
export function InlineSlider({
  label,
  min = 0,
  max: suppliedMax = 100,
  step: suppliedStep = 1,
  value,
  defaultValue,
  onValueChange,
  format = defaultFormat,
  disabled = false,
  showTicks = true,
  accessibilityLabel,
  formatValueText,
  backgroundColor = "#f4f4f5",
  foregroundColor = "#18181b",
  fillColor = "rgba(24,24,27,0.15)",
  tickColor = "rgba(24,24,27,0.25)",
  height = 40,
  radius = 8,
  textInset = 20,
  labelFontSize = 14,
  valueFontSize = 13,
  reduceMotion,
  style,
  testID,
}: InlineSliderProps) {
  const max = Math.max(min, suppliedMax)
  const step =
    suppliedStep > 0 && Number.isFinite(suppliedStep) ? suppliedStep : 1
  const precision =
    step.toFixed(6).replace(/0+$/, "").split(".")[1]?.length ?? 0
  const trackHeight = Math.max(40, height)
  const touchHeight = Math.max(48, trackHeight)
  const [internal, setInternal] = useState(defaultValue ?? min)
  const current = clamp(value !== undefined ? value : internal, min, max)
  const [width, setWidth] = useState(292)
  const [labelWidth, setLabelWidth] = useState(0)
  const [readoutWidth, setReadoutWidth] = useState(24)
  const startX = 8
  const endX = Math.max(startX, width - 12)
  const systemReducedMotion = useReducedMotion()
  const reduce = reduceMotion ?? systemReducedMotion
  const motion =
    reduceMotion === undefined
      ? ReduceMotion.System
      : reduceMotion
        ? ReduceMotion.Always
        : ReduceMotion.Never
  const stops = useMemo(() => {
    const values: number[] = []
    for (let index = 0; index < 10; index++) {
      const point = min + (index / 9) * (max - min)
      const snapped =
        index === 9
          ? max
          : clamp(
              Number(
                (min + Math.round((point - min) / step) * step).toFixed(6)
              ),
              min,
              max
            )
      if (!values.includes(snapped)) values.push(snapped)
    }
    return values.map((stop, index) => ({
      value: stop,
      x:
        values.length === 1
          ? startX
          : startX + (index / (values.length - 1)) * (endX - startX),
    }))
  }, [min, max, step, endX])
  const restingX = mapStops(stops, current, "value", "x")
  const handleX = useSharedValue(restingX)
  const displayedValue = useSharedValue(current)
  const grabScale = useSharedValue(1)
  const dragging = useSharedValue(false)
  const grabOffset = useSharedValue(2)
  useEffect(() => {
    if (!dragging.get()) {
      handleX.set(withSpring(restingX, { ...GLIDE, reduceMotion: motion }))
      displayedValue.set(current)
    }
  }, [restingX, current, motion, dragging, handleX, displayedValue])
  const commit = useCallback(
    (next: number) => {
      if (value === undefined) setInternal(next)
      onValueChange?.(next)
    },
    [value, onValueChange]
  )
  const pan = useMemo(
    () =>
      Gesture.Pan()
        .enabled(!disabled && max > min)
        .maxPointers(1)
        .minDistance(0)
        .onStart((event) => {
          cancelAnimation(handleX)
          const thumb = handleX.get()
          grabOffset.set(
            Math.abs(event.x - thumb - 2) <= 12 ? event.x - thumb : 2
          )
          dragging.set(true)
          grabScale.set(
            reduce ? 1 : withSpring(1.35, { ...GRAB, reduceMotion: motion })
          )
        })
        .onUpdate((event) => {
          const x = clamp(event.x - grabOffset.get(), startX, endX)
          handleX.set(x)
          const raw = mapStops(stops, x, "x", "value")
          displayedValue.set(Number(raw.toFixed(precision)))
        })
        .onEnd((event, success) => {
          if (!success) return
          const stop = closestStop(
            stops,
            clamp(event.x - grabOffset.get(), startX, endX)
          )
          handleX.set(
            withSpring(value === undefined ? stop.x : restingX, {
              ...GLIDE,
              reduceMotion: motion,
            })
          )
          displayedValue.set(value === undefined ? stop.value : current)
          scheduleOnRN(commit, stop.value)
        })
        .onFinalize((_event, success) => {
          dragging.set(false)
          grabScale.set(withSpring(1, { ...GRAB, reduceMotion: motion }))
          if (!success) {
            handleX.set(
              withSpring(restingX, { ...GLIDE, reduceMotion: motion })
            )
            displayedValue.set(current)
          }
        }),
    [
      disabled,
      min,
      max,
      handleX,
      grabOffset,
      dragging,
      grabScale,
      motion,
      startX,
      endX,
      stops,
      displayedValue,
      precision,
      value,
      restingX,
      current,
      commit,
      reduce,
    ]
  )
  const split = useDerivedValue(() => {
    const x = handleX.get()
    const overlap = (start: number, end: number) =>
      Math.max(0, Math.min(1, (x + 4 - start) / 6, (end - x) / 6))
    return Math.max(
      overlap(textInset, textInset + labelWidth),
      overlap(width - textInset - readoutWidth, width - textInset)
    )
  })
  const fillStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateX:
          (handleX.get() >= endX ? width - 2 : handleX.get() + 8) - width + 2,
      },
    ],
  }))
  const handleStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: handleX.get() }, { scaleY: grabScale.get() }],
  }))
  const stemStyle = useAnimatedStyle(() => ({ opacity: 1 - split.get() }))
  const topStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: reduce ? 0 : -split.get() }],
  }))
  const bottomStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: reduce ? 0 : split.get() }],
  }))
  const textProps = useAnimatedProps<NativeReadoutProps>(() => ({
    text: format(displayedValue.get()),
  }))
  const measureText = useMemo(() => {
    const candidates = stops.flatMap((stop) => [
      format(stop.value),
      format(Number(stop.value.toFixed(precision))),
    ])
    if (precision > 0)
      candidates.push(
        format(Number((max - 10 ** -precision).toFixed(precision))),
        format(Number((min + 10 ** -precision).toFixed(precision)))
      )
    return candidates.sort((a, b) => b.length - a.length)[0] ?? format(current)
  }, [stops, precision, format, max, current])
  const valueStyle = {
    color: foregroundColor,
    fontSize: valueFontSize,
    fontWeight: "600" as const,
    fontVariant: ["tabular-nums"] as "tabular-nums"[],
  }
  const visibleTicks = showTicks
    ? stops.filter(({ x }) => {
        const hitsLabel = x + 2 >= textInset && x - 2 <= textInset + labelWidth
        const hitsValue =
          x + 2 >= width - textInset - readoutWidth &&
          x - 2 <= width - textInset
        return !hitsLabel && !hitsValue
      })
    : []
  const adjust = (direction: "increment" | "decrement") => {
    if (disabled || max === min) return
    let next = current
    if (direction === "increment")
      next = stops.find((stop) => stop.value > current)?.value ?? max
    else {
      for (let i = stops.length - 1; i >= 0; i--) {
        if (stops[i].value < current) {
          next = stops[i].value
          break
        }
      }
    }
    if (next !== current) commit(next)
  }
  return (
    <GestureDetector gesture={pan}>
      <View
        testID={testID}
        collapsable={false}
        accessible
        accessibilityRole="adjustable"
        accessibilityLabel={accessibilityLabel ?? label}
        accessibilityState={{ disabled: disabled || max === min }}
        aria-disabled={disabled || max === min}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={current}
        aria-valuetext={(formatValueText ?? format)(current)}
        accessibilityValue={{
          min,
          max,
          now: current,
          text: (formatValueText ?? format)(current),
        }}
        accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
        onAccessibilityAction={({ nativeEvent }) => {
          if (
            nativeEvent.actionName === "increment" ||
            nativeEvent.actionName === "decrement"
          )
            adjust(nativeEvent.actionName)
        }}
        onLayout={({ nativeEvent }) => {
          const next = nativeEvent.layout.width
          if (next > 0)
            setWidth((previous) =>
              Math.abs(next - previous) < 0.5 ? previous : next
            )
        }}
        style={[
          styles.root,
          { minHeight: touchHeight, opacity: disabled ? 0.5 : 1 },
          style,
        ]}
      >
        <View
          pointerEvents="none"
          accessibilityElementsHidden
          aria-hidden
          importantForAccessibility="no-hide-descendants"
          style={[
            styles.track,
            { height: trackHeight, borderRadius: radius, backgroundColor },
          ]}
        >
          <View style={[styles.fillClip, { borderRadius: radius }]}>
            <Animated.View
              style={[
                StyleSheet.absoluteFill,
                { backgroundColor: fillColor, borderRadius: radius },
                fillStyle,
              ]}
            />
          </View>
          <Text
            numberOfLines={1}
            onLayout={({ nativeEvent }) =>
              setLabelWidth(nativeEvent.layout.width)
            }
            style={[
              styles.label,
              {
                left: textInset,
                color: foregroundColor,
                fontSize: labelFontSize,
              },
            ]}
          >
            {label}
          </Text>
          <Text
            numberOfLines={1}
            accessible={false}
            onLayout={({ nativeEvent }) =>
              setReadoutWidth(Math.max(1, nativeEvent.layout.width))
            }
            style={[styles.measure, valueStyle, { right: textInset }]}
          >
            {measureText}
          </Text>
          <Readout
            editable={false}
            caretHidden
            contextMenuHidden
            accessible={false}
            pointerEvents="none"
            underlineColorAndroid="transparent"
            defaultValue={format(current)}
            animatedProps={textProps}
            style={[
              styles.readout,
              valueStyle,
              { right: textInset, width: readoutWidth },
            ]}
          />
          {visibleTicks.map(({ x }) => (
            <View
              key={x}
              style={[styles.tick, { left: x, backgroundColor: tickColor }]}
            />
          ))}
          <Animated.View
            style={[
              styles.handle,
              { top: (trackHeight - 24) / 2 },
              handleStyle,
            ]}
          >
            <Animated.View
              style={[
                styles.stem,
                { backgroundColor: foregroundColor },
                stemStyle,
              ]}
            />
            <Animated.View
              style={[
                styles.cap,
                { top: 0, backgroundColor: foregroundColor },
                topStyle,
              ]}
            />
            <Animated.View
              style={[
                styles.cap,
                { bottom: 0, backgroundColor: foregroundColor },
                bottomStyle,
              ]}
            />
          </Animated.View>
        </View>
      </View>
    </GestureDetector>
  )
}
const styles = StyleSheet.create({
  root: { width: "100%", justifyContent: "center", direction: "ltr" },
  track: { overflow: "hidden", justifyContent: "center" },
  fillClip: {
    position: "absolute",
    left: 2,
    right: 2,
    top: 0,
    bottom: 0,
    overflow: "hidden",
  },
  label: {
    position: "absolute",
    maxWidth: "40%",
    fontWeight: "500",
    lineHeight: 20,
  },
  measure: { position: "absolute", opacity: 0, maxWidth: "40%" },
  readout: {
    position: "absolute",
    padding: 0,
    margin: 0,
    height: 24,
    maxWidth: "40%",
    textAlign: "right",
    textAlignVertical: "center",
    includeFontPadding: false,
  },
  tick: {
    position: "absolute",
    top: "50%",
    marginTop: -2,
    width: 4,
    height: 4,
    borderRadius: 2,
  },
  handle: { position: "absolute", left: 0, width: 4, height: 24 },
  stem: { width: 4, height: 24, borderRadius: 2 },
  cap: { position: "absolute", width: 4, height: 4, borderRadius: 2 },
})

Give the gestures and motion a run on a real Android phone.

Back to Be UI