Skip to content
Native Animation

React Native Slider with Reanimated: A Stepped Input

By Native Animation · Updated

A React Native slider turns a continuous drag into a value within a range. This example builds a horizontal, single-thumb input from zero to one hundred in steps of five. Reanimated owns the visual position during a drag; the parent owns the accepted value. A new gesture interrupts the settling animation, and a canceled gesture restores the last accepted value.

If you need an ordinary platform slider, start with the Expo SDK 58 community slider. Build a custom gesture when its geometry or material is part of your product. A slider button instead confirms an action; a progress bar reports work already completed.

Setup

This tutorial targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3. Install the SDK-compatible packages:

npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler

Use bunx expo install in a Bun project. Follow the SDK 58 Reanimated setup and keep your screen inside a GestureHandlerRootView. Put this file outside the Expo Router route directory and render SliderExample from a screen. No Skia, blur, or gradient dependency is required for this teaching example.

Build a controlled slider

Create SteppedSlider.tsx. The parent should store values in the zero-to-one-hundred range; the component clamps and rounds incoming values defensively.

The Gesture.* builder API remains available in Gesture Handler 3.3, but is deprecated in favor of its hook API. The example retains the builders.

import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
  cancelAnimation, useAnimatedStyle, useReducedMotion,
  useSharedValue, withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

function quantize(value: number) {
  'worklet';
  return Math.round(Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)) / 5) * 5;
}

export function SteppedSlider({ value, onChange }: {
  value: number;
  onChange: (value: number) => void;
}) {
  const accepted = quantize(value);
  const [width, setWidth] = useState(0);
  const progress = useSharedValue(accepted / 100);
  const origin = useSharedValue(0);
  const dragging = useSharedValue(false);
  const reduceMotion = useReducedMotion();
  const travel = Math.max(1, width - 48);

  useEffect(() => {
    if (!dragging.get()) {
      progress.set(reduceMotion ? accepted / 100 : withTiming(accepted / 100, { duration: 180 }));
    }
  }, [accepted, dragging, progress, reduceMotion]);

  function request(next: number) {
    // The host accepts or rejects the request. Restore its current value first.
    progress.set(reduceMotion ? accepted / 100 : withTiming(accepted / 100, { duration: 180 }));
    onChange(quantize(next));
  }

  const pan = Gesture.Pan()
    .enabled(width > 48)
    .activeOffsetX([-6, 6])
    .failOffsetY([-16, 16])
    .onStart(() => {
      cancelAnimation(progress);
      dragging.set(true);
      origin.set(progress.get());
    })
    .onUpdate((event) => {
      progress.set(Math.max(0, Math.min(1, origin.get() + event.translationX / travel)));
    })
    .onEnd(() => scheduleOnRN(request, quantize(progress.get() * 100)))
    .onFinalize((_event, success) => {
      dragging.set(false);
      if (!success) {
        progress.set(reduceMotion ? accepted / 100 : withTiming(accepted / 100, { duration: 180 }));
      }
    });

  const thumbStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: progress.get() * travel }],
  }));
  const fillStyle = useAnimatedStyle(() => ({ width: 24 + progress.get() * travel }));

  return (
    <View style={styles.control}>
      <GestureDetector gesture={pan} touchAction="pan-y">
        <Animated.View
          accessible
          accessibilityRole="adjustable"
          accessibilityLabel="Intensity"
          accessibilityValue={{ min: 0, max: 100, now: accepted, text: accepted + '%' }}
          accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
          onAccessibilityAction={({ nativeEvent }) => {
            if (nativeEvent.actionName === 'increment') request(accepted + 5);
            if (nativeEvent.actionName === 'decrement') request(accepted - 5);
          }}
          onLayout={({ nativeEvent }) => setWidth(nativeEvent.layout.width)}
          style={styles.rail}
        >
          <View pointerEvents="none" style={styles.track}>
            <Animated.View style={[styles.fill, fillStyle]} />
          </View>
          <Animated.View pointerEvents="none" style={[styles.thumb, thumbStyle]} />
        </Animated.View>
      </GestureDetector>
      <View style={styles.buttons}>
        <Pressable accessibilityRole="button" accessibilityLabel="Decrease intensity"
          disabled={accepted === 0} onPress={() => request(accepted - 5)} style={styles.button}>
          <Text>Decrease</Text>
        </Pressable>
        <Text>{accepted}%</Text>
        <Pressable accessibilityRole="button" accessibilityLabel="Increase intensity"
          disabled={accepted === 100} onPress={() => request(accepted + 5)} style={styles.button}>
          <Text>Increase</Text>
        </Pressable>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  control: { width: '100%', maxWidth: 400, alignSelf: 'center', direction: 'ltr' },
  rail: { height: 56, justifyContent: 'center' },
  track: { position: 'absolute', left: 0, right: 0, height: 8,
    borderRadius: 4, backgroundColor: '#E5E1EF', overflow: 'hidden' },
  fill: { height: 8, backgroundColor: '#6144AD' },
  thumb: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#6144AD' },
  buttons: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  button: { minHeight: 48, paddingHorizontal: 8, justifyContent: 'center' },
});

export function SliderExample() {
  const [value, setValue] = useState(50);
  return (
    <GestureHandlerRootView style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
      <SteppedSlider value={value} onChange={setValue} />
    </GestureHandlerRootView>
  );
}

Map distance to values

The thumb is forty-eight points wide, so its travel is the measured rail width minus forty-eight. Progress zero puts its left edge at zero; progress one puts its right edge at the rail's right edge. The fill ends beneath its center by adding twenty-four points. Measuring the parent rather than the screen keeps the control usable in cards and split layouts.

The drag begins from the current animated position, not the last React value. Translation divided by travel produces normalized progress. Clamp while dragging, then round to a five-point step at release. For a different range, convert progress with min + progress * (max - min) and quantize relative to min. Define how a final partial step reaches max before adding arbitrary ranges.

Keep commits separate from animation

Gesture updates stay on the UI thread. scheduleOnRN calls request once on release. That function restores the host's accepted value and requests a new one. When the parent accepts synchronously, its next render animates toward the requested step; if it rejects the request, the old value remains. Button and accessibility actions use the same path.

External values received during an active drag do not take over the finger. Canceling restores the latest accepted value; a successful release requests the user's selection. For a server-controlled setting, update local accepted state immediately and persist separately, or explicitly show a pending state. Avoid an unbounded queue of network requests while dragging.

Scrolling, accessibility, and verification

Horizontal activation and vertical failure thresholds let a vertical parent scroll before the pan activates. On web, pan-y preserves vertical touch scrolling. This example uses left-to-right geometry explicitly; mirror both translation and layout if your product supports right-to-left ranges. It selects values by dragging or labeled buttons, without implementing track taps or a two-thumb range.

The adjustable role reports the committed value, and increment/decrement actions match the visible controls. Reduced Motion removes settling animation while retaining direct finger tracking. Verify a narrow container, rotation, repeated endpoint presses, parent rejection, external updates during a drag, canceled gestures, screen readers, and large text on your target devices.

What the paid component adds

The published Liquid Value Slider uses a vertical glass vessel instead of this horizontal rail. Its liquid meniscus bends with drag velocity, settles through a damped response, and snaps to configurable steps. It exposes min, max, step, controlled value, formatting, onChange, onChangeEnd, track taps, and separate adjustment buttons. Choose it when the liquid value display fits your screen; it is not a drop-in stock horizontal slider.

For a horizontal sound mixer, compare Gooey Merge Slider, whose rows have liquid thumbs, value-change callbacks, and a release callback. Both catalog pieces require Skia and its web setup. Read their current props and platform status before integration; the tutorial above needs neither canvas nor paid source.

For a rotary input, see the circular slider guide for angle mapping, bounded steps, and adjustable accessibility.

For a measurement scale with labeled marks, see the ruler picker guide: it covers efficient ticks, displacement mapping, bounded inertia, and unit-aware controlled values.

FAQ

Can I use this React Native slider in Expo?

Yes, with the SDK-compatible packages and a GestureHandlerRootView. The custom example uses React Native views and Reanimated, without an additional native slider module.

Does the slider update React on every frame?

No. The thumb and fill follow a shared value. React receives the requested selection on release or a button action. Add throttled previews separately if your application needs them.

Is this a range slider?

No. This example selects one value. A two-thumb control also needs ordering rules, separate accessible values, overlap handling, and a way to choose each thumb.

For a ready-made two-thumb control, the vertical-range-window piece sets each limit with its own handle and lets you drag the selected band to shift both at once.