Slider
Pick a number in a range by dragging or tapping (Gesture Handler + Reanimated), snapped to steps with a haptic tick; adjustable for screen readers.
Light
Dark
Your app is ready.
Inter body text, Inter headings.
a0: Vega, neutral, neutral accent, default radius, Inter.Change PresetInstallation
npx nativecn-cli@latest add sliderAlso installs: theme, form-field-context, use-controllable-state, use-motion.
Packages: expo-haptics, react-native-gesture-handler, react-native-reanimated, react-native-worklets.
Usage
Slider demo
A controlled volume Slider with its value shown, and a stepped rating.
import { useState } from "react";
import { View } from "react-native";
import { Slider } from "@/components/slider";
import { Text } from "@/components/text";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({ root: { gap: t.spacing[4] } }));
/** A controlled volume Slider with its value shown, and an uncontrolled stepped one. */
export function SliderDemo() {
const styles = useStyles();
const [volume, setVolume] = useState(40);
return (
<View style={styles.root}>
<Text variant="label">Volume: {volume}%</Text>
<Slider
aria-label="Volume"
aria-valuetext={`${volume} percent`}
value={volume}
onValueChange={setVolume}
/>
<Text variant="label">Rating</Text>
<Slider aria-label="Rating" min={1} max={5} defaultValue={3} />
</View>
);
}Slider demo
A controlled volume Slider with its value shown, and a stepped rating.
import { useState } from "react";
import { View } from "react-native";
import { Slider } from "@/components/slider";
import { Text } from "@/components/text";
import { createStyles } from "@/theme";
const useStyles = createStyles((t) => ({ root: { gap: t.spacing[4] } }));
/** A controlled volume Slider with its value shown, and an uncontrolled stepped one. */
export function SliderDemo() {
const styles = useStyles();
const [volume, setVolume] = useState(40);
return (
<View style={styles.root}>
<Text variant="label">Volume: {volume}%</Text>
<Slider
aria-label="Volume"
aria-valuetext={`${volume} percent`}
value={volume}
onValueChange={setVolume}
/>
<Text variant="label">Rating</Text>
<Slider aria-label="Rating" min={1} max={5} defaultValue={3} />
</View>
);
}Props
| Prop | Type and description |
|---|---|
value | number (controlled) |
defaultValue | number (uncontrolled, default min) |
onValueChange | (value: number) => void: each new stepped value while dragging, and on increment/decrement |
onSlidingComplete | (value: number) => void: when a drag or tap ends, and after increment/decrement |
min | number (default 0) |
max | number (default 100) |
step | number (default 1): values always snap to a step |
disabled | boolean: no dragging or actions, dimmed |
aria-label | string: the accessible name (a FormField label provides it) |
aria-valuetext | string: a spoken value with units, e.g. "40 percent" |
style | ViewStyle, layout only (e.g. width, flex), merged last onto the outermost View |
Accessibility
- An adjustable control: role slider on Android and web, and accessibilityRole adjustable on iOS (a recorded exception, so VoiceOver users can swipe up and down).
- It exposes aria-valuemin, aria-valuemax and aria-valuenow; the increment and decrement actions move it one step.
- Name it with aria-label (a FormField label provides it); aria-valuetext gives a spoken value with units.
- The row is at least 48 high.
- disabled blocks dragging and the actions, and is announced as disabled.
- The thumb moves instantly under Reduce Motion.
Notes
Brings its own small GestureHandlerRootView, sized to the Slider, so it works whether or not the app root has one (Expo Router does not add one). The drag runs on the UI thread; horizontal drags only, so a vertical scroll still works. A light haptic tick per step when config.haptics is on. Track and thumb sizes come from the slider.track and slider.thumb Style Slots.
Source
The Vega Style's code, exactly as add copies it into your app.
{components}/slider.tsx
import * as Haptics from "expo-haptics";
import { useEffect, useState, type Ref } from "react";
import {
Platform,
View,
type AccessibilityActionEvent,
type LayoutChangeEvent,
type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useFormField } from "@/components/primitives/form-field-context";
import { useControllableState } from "@/hooks/use-controllable-state";
import { useMotion } from "@/hooks/use-motion";
import { createStyles, useTheme } from "@/theme";
export type SliderProps = Omit<
ViewProps,
| "style"
| "children"
| "role"
| "accessibilityRole"
| "accessibilityActions"
| "onAccessibilityAction"
> & {
/** The controlled value. */
value?: number;
/** The starting value while uncontrolled (default `min`). */
defaultValue?: number;
/** Called with each new stepped value while dragging, and on increment/decrement. */
onValueChange?: (value: number) => void;
/** Called with the final value when a drag or tap ends, and after increment/decrement. */
onSlidingComplete?: (value: number) => void;
/** Default `0`. */
min?: number;
/** Default `100`. */
max?: number;
/** The distance between allowed values (default `1`). Values always snap to a step. */
step?: number;
/** Blocks dragging and the accessibility actions, and is announced as disabled. */
disabled?: boolean;
/** Layout only (e.g. width, flex, margins), merged last onto the outermost View. */
style?: StyleProp<ViewStyle>;
ref?: Ref<View>;
};
/** Clamp to [min, max] and snap to the nearest step from `min`. Runs on the UI thread too. */
export function snapToStep(value: number, min: number, max: number, step: number): number {
"worklet";
if (max <= min) return min;
const clamped = Math.min(max, Math.max(min, value));
if (!(step > 0)) return clamped;
const snapped = min + Math.round((clamped - min) / step) * step;
// Round away float noise (0.1 + 0.2), then keep the last step inside the range.
const decimals = (String(step).split(".")[1] ?? "").length;
const rounded = Number(snapped.toFixed(Math.min(decimals, 20)));
return Math.min(max, rounded);
}
/**
* The adjustable role. React Native maps `role="slider"` to Android's adjustable role, but on iOS
* (Fabric) it sets no trait, so VoiceOver would not offer swipe up/down. iOS therefore gets
* `accessibilityRole="adjustable"` (UIAccessibilityTraitAdjustable), the one `accessibility*`
* spelling here because the `role` spelling does not work for it.
*/
const adjustableRole =
Platform.OS === "ios"
? ({ accessibilityRole: "adjustable" } as const)
: ({ role: "slider" } as const);
// Haptics are best effort: an unsupported device must never break a drag.
function tick() {
try {
Haptics.selectionAsync().catch(() => {});
} catch {
// Ignored, see above.
}
}
const useStyles = createStyles((t) => {
const thumb = { width: t.scaleValue(24), height: t.scaleValue(24) };
const track = { height: t.scaleValue(6) };
return {
// GestureHandlerRootView defaults to flex: 1; this one stays the Slider's own size.
gestureRoot: { flex: 0 },
// At least the 48 touch target tall, so the whole row is easy to grab.
root: {
flexGrow: 1,
height: Math.max(Number(thumb.height), t.minTouchTarget),
justifyContent: "center",
},
track: {
...track,
borderRadius: t.radius.full,
backgroundColor: t.colors.secondary,
overflow: "hidden",
marginHorizontal: Number(thumb.width) / 2,
},
range: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
backgroundColor: t.colors.primary,
},
thumb: {
...thumb,
position: "absolute",
left: 0,
borderRadius: t.radius.full,
backgroundColor: t.colors.background,
borderWidth: t.borderWidth.default,
borderColor: t.colors.primary,
boxShadow: t.elevation.sm,
},
disabled: { opacity: t.opacity.disabled },
};
});
/**
* A Slider: drag or tap the track (Gesture Handler + Reanimated, on the UI thread) to pick a
* value between `min` and `max`, snapped to `step`, with a light haptic tick per step when
* `config.haptics` is on. Screen readers get an adjustable control with
* `aria-valuemin`/`max`/`now` and increment/decrement actions (swipe up/down). Moves are instant
* under Reduce Motion. Inside FormField it takes its label and disabled state.
*/
export function Slider({
value,
defaultValue,
onValueChange,
onSlidingComplete,
min = 0,
max = 100,
step = 1,
disabled,
style,
onLayout,
testID,
"aria-label": ariaLabel,
accessibilityHint,
...props
}: SliderProps) {
const field = useFormField();
const { config } = useTheme();
const motion = useMotion();
const styles = useStyles();
const isDisabled = disabled ?? field?.disabled ?? false;
const [raw, setValue] = useControllableState({
value,
defaultValue: defaultValue ?? min,
onChange: onValueChange,
});
const current = snapToStep(raw, min, max, step);
const fraction = max > min ? (current - min) / (max - min) : 0;
const thumbSize = Number(styles.thumb.width);
const width = useSharedValue(0);
const progress = useSharedValue(fraction);
const dragging = useSharedValue(false);
/** The last stepped value reported during the current drag. */
const lastValue = useSharedValue(current);
// Bumped when a drag or tap ends, so the thumb settles on the value the parent kept.
const [settles, setSettles] = useState(0);
// Follow the value (controlled updates, accessibility actions, the end of a drag).
useEffect(() => {
if (dragging.get()) return;
progress.set(withTiming(fraction, motion.timing("fast")));
lastValue.set(current);
}, [fraction, current, settles, motion, progress, dragging, lastValue]);
// A step reached while dragging: update the value and tick.
const onStep = (next: number) => {
setValue(next);
if (config.haptics) tick();
};
const onEnd = (final: number) => {
onSlidingComplete?.(final);
setSettles((n) => n + 1);
};
const moveTo = (x: number) => {
"worklet";
const usable = width.get() - thumbSize;
if (usable <= 0) return;
const ratio = Math.min(1, Math.max(0, (x - thumbSize / 2) / usable));
const next = snapToStep(min + ratio * (max - min), min, max, step);
progress.set(max > min ? (next - min) / (max - min) : 0);
if (next !== lastValue.get()) {
lastValue.set(next);
scheduleOnRN(onStep, next);
}
};
const end = () => {
"worklet";
dragging.set(false);
scheduleOnRN(onEnd, lastValue.get());
};
const pan = Gesture.Pan()
.enabled(!isDisabled)
// Horizontal drags only, so a vertical scroll in the Screen still works.
.activeOffsetX([-4, 4])
.failOffsetY([-12, 12])
.onStart((e) => {
dragging.set(true);
moveTo(e.x);
})
.onUpdate((e) => moveTo(e.x))
.onEnd(end);
const tap = Gesture.Tap()
.enabled(!isDisabled)
.onEnd((e, success) => {
if (!success) return;
dragging.set(true);
moveTo(e.x);
end();
});
// Test ids let tests fire the gestures (react-native-gesture-handler/jest-utils).
if (testID) {
pan.withTestId(`${testID}-pan`);
tap.withTestId(`${testID}-tap`);
}
const gesture = Gesture.Race(pan, tap);
const handleLayout = (event: LayoutChangeEvent) => {
width.set(event.nativeEvent.layout.width);
onLayout?.(event);
};
const thumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: progress.get() * Math.max(0, width.get() - thumbSize) }],
}));
const rangeStyle = useAnimatedStyle(() => ({
width: progress.get() * Math.max(0, width.get() - thumbSize),
}));
const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
if (isDisabled) return;
const action = event.nativeEvent.actionName;
if (action !== "increment" && action !== "decrement") return;
const delta = step > 0 ? step : (max - min) / 10;
const direction = action === "increment" ? 1 : -1;
const next = snapToStep(current + direction * delta, min, max, step);
if (next === current) return;
setValue(next);
onSlidingComplete?.(next);
};
return (
// The drag needs a gesture root above it, and the app's root Layout may not have one (#153).
// This one is only as big as the Slider, so it takes no touches meant for the Screen; inside
// another gesture root it simply defers to it. `style` lays it out, as the outermost View.
<GestureHandlerRootView style={[styles.gestureRoot, style]}>
<GestureDetector gesture={gesture}>
<View
accessible
{...adjustableRole}
aria-label={ariaLabel ?? field?.accessibilityProps["aria-label"]}
accessibilityHint={accessibilityHint ?? field?.accessibilityProps.accessibilityHint}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current}
aria-disabled={isDisabled}
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
onAccessibilityAction={handleAccessibilityAction}
onLayout={handleLayout}
testID={testID}
style={[styles.root, isDisabled ? styles.disabled : null]}
{...props}
>
<View style={styles.track}>
<Animated.View style={[styles.range, rangeStyle]} />
</View>
<Animated.View
testID={testID ? `${testID}-thumb` : undefined}
style={[styles.thumb, thumbStyle]}
/>
</View>
</GestureDetector>
</GestureHandlerRootView>
);
}{components}/slider.tsx
import * as Haptics from "expo-haptics";
import { useEffect, useState, type Ref } from "react";
import {
Platform,
View,
type AccessibilityActionEvent,
type LayoutChangeEvent,
type StyleProp,
type ViewProps,
type ViewStyle,
} from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useFormField } from "@/components/primitives/form-field-context";
import { useControllableState } from "@/hooks/use-controllable-state";
import { useMotion } from "@/hooks/use-motion";
import { createStyles, useTheme } from "@/theme";
export type SliderProps = Omit<
ViewProps,
| "style"
| "children"
| "role"
| "accessibilityRole"
| "accessibilityActions"
| "onAccessibilityAction"
> & {
/** The controlled value. */
value?: number;
/** The starting value while uncontrolled (default `min`). */
defaultValue?: number;
/** Called with each new stepped value while dragging, and on increment/decrement. */
onValueChange?: (value: number) => void;
/** Called with the final value when a drag or tap ends, and after increment/decrement. */
onSlidingComplete?: (value: number) => void;
/** Default `0`. */
min?: number;
/** Default `100`. */
max?: number;
/** The distance between allowed values (default `1`). Values always snap to a step. */
step?: number;
/** Blocks dragging and the accessibility actions, and is announced as disabled. */
disabled?: boolean;
/** Layout only (e.g. width, flex, margins), merged last onto the outermost View. */
style?: StyleProp<ViewStyle>;
ref?: Ref<View>;
};
/** Clamp to [min, max] and snap to the nearest step from `min`. Runs on the UI thread too. */
export function snapToStep(value: number, min: number, max: number, step: number): number {
"worklet";
if (max <= min) return min;
const clamped = Math.min(max, Math.max(min, value));
if (!(step > 0)) return clamped;
const snapped = min + Math.round((clamped - min) / step) * step;
// Round away float noise (0.1 + 0.2), then keep the last step inside the range.
const decimals = (String(step).split(".")[1] ?? "").length;
const rounded = Number(snapped.toFixed(Math.min(decimals, 20)));
return Math.min(max, rounded);
}
/**
* The adjustable role. React Native maps `role="slider"` to Android's adjustable role, but on iOS
* (Fabric) it sets no trait, so VoiceOver would not offer swipe up/down. iOS therefore gets
* `accessibilityRole="adjustable"` (UIAccessibilityTraitAdjustable), the one `accessibility*`
* spelling here because the `role` spelling does not work for it.
*/
const adjustableRole =
Platform.OS === "ios"
? ({ accessibilityRole: "adjustable" } as const)
: ({ role: "slider" } as const);
// Haptics are best effort: an unsupported device must never break a drag.
function tick() {
try {
Haptics.selectionAsync().catch(() => {});
} catch {
// Ignored, see above.
}
}
const useStyles = createStyles((t) => {
const thumb = { width: t.scaleValue(20), height: t.scaleValue(20) };
const track = { height: t.scaleValue(4) };
return {
// GestureHandlerRootView defaults to flex: 1; this one stays the Slider's own size.
gestureRoot: { flex: 0 },
// At least the 48 touch target tall, so the whole row is easy to grab.
root: {
flexGrow: 1,
height: Math.max(Number(thumb.height), t.minTouchTarget),
justifyContent: "center",
},
track: {
...track,
borderRadius: t.radius.full,
backgroundColor: t.colors.secondary,
overflow: "hidden",
marginHorizontal: Number(thumb.width) / 2,
},
range: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
backgroundColor: t.colors.primary,
},
thumb: {
...thumb,
position: "absolute",
left: 0,
borderRadius: t.radius.full,
backgroundColor: t.colors.background,
borderWidth: t.borderWidth.default,
borderColor: t.colors.primary,
boxShadow: t.elevation.sm,
},
disabled: { opacity: t.opacity.disabled },
};
});
/**
* A Slider: drag or tap the track (Gesture Handler + Reanimated, on the UI thread) to pick a
* value between `min` and `max`, snapped to `step`, with a light haptic tick per step when
* `config.haptics` is on. Screen readers get an adjustable control with
* `aria-valuemin`/`max`/`now` and increment/decrement actions (swipe up/down). Moves are instant
* under Reduce Motion. Inside FormField it takes its label and disabled state.
*/
export function Slider({
value,
defaultValue,
onValueChange,
onSlidingComplete,
min = 0,
max = 100,
step = 1,
disabled,
style,
onLayout,
testID,
"aria-label": ariaLabel,
accessibilityHint,
...props
}: SliderProps) {
const field = useFormField();
const { config } = useTheme();
const motion = useMotion();
const styles = useStyles();
const isDisabled = disabled ?? field?.disabled ?? false;
const [raw, setValue] = useControllableState({
value,
defaultValue: defaultValue ?? min,
onChange: onValueChange,
});
const current = snapToStep(raw, min, max, step);
const fraction = max > min ? (current - min) / (max - min) : 0;
const thumbSize = Number(styles.thumb.width);
const width = useSharedValue(0);
const progress = useSharedValue(fraction);
const dragging = useSharedValue(false);
/** The last stepped value reported during the current drag. */
const lastValue = useSharedValue(current);
// Bumped when a drag or tap ends, so the thumb settles on the value the parent kept.
const [settles, setSettles] = useState(0);
// Follow the value (controlled updates, accessibility actions, the end of a drag).
useEffect(() => {
if (dragging.get()) return;
progress.set(withTiming(fraction, motion.timing("fast")));
lastValue.set(current);
}, [fraction, current, settles, motion, progress, dragging, lastValue]);
// A step reached while dragging: update the value and tick.
const onStep = (next: number) => {
setValue(next);
if (config.haptics) tick();
};
const onEnd = (final: number) => {
onSlidingComplete?.(final);
setSettles((n) => n + 1);
};
const moveTo = (x: number) => {
"worklet";
const usable = width.get() - thumbSize;
if (usable <= 0) return;
const ratio = Math.min(1, Math.max(0, (x - thumbSize / 2) / usable));
const next = snapToStep(min + ratio * (max - min), min, max, step);
progress.set(max > min ? (next - min) / (max - min) : 0);
if (next !== lastValue.get()) {
lastValue.set(next);
scheduleOnRN(onStep, next);
}
};
const end = () => {
"worklet";
dragging.set(false);
scheduleOnRN(onEnd, lastValue.get());
};
const pan = Gesture.Pan()
.enabled(!isDisabled)
// Horizontal drags only, so a vertical scroll in the Screen still works.
.activeOffsetX([-4, 4])
.failOffsetY([-12, 12])
.onStart((e) => {
dragging.set(true);
moveTo(e.x);
})
.onUpdate((e) => moveTo(e.x))
.onEnd(end);
const tap = Gesture.Tap()
.enabled(!isDisabled)
.onEnd((e, success) => {
if (!success) return;
dragging.set(true);
moveTo(e.x);
end();
});
// Test ids let tests fire the gestures (react-native-gesture-handler/jest-utils).
if (testID) {
pan.withTestId(`${testID}-pan`);
tap.withTestId(`${testID}-tap`);
}
const gesture = Gesture.Race(pan, tap);
const handleLayout = (event: LayoutChangeEvent) => {
width.set(event.nativeEvent.layout.width);
onLayout?.(event);
};
const thumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: progress.get() * Math.max(0, width.get() - thumbSize) }],
}));
const rangeStyle = useAnimatedStyle(() => ({
width: progress.get() * Math.max(0, width.get() - thumbSize),
}));
const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
if (isDisabled) return;
const action = event.nativeEvent.actionName;
if (action !== "increment" && action !== "decrement") return;
const delta = step > 0 ? step : (max - min) / 10;
const direction = action === "increment" ? 1 : -1;
const next = snapToStep(current + direction * delta, min, max, step);
if (next === current) return;
setValue(next);
onSlidingComplete?.(next);
};
return (
// The drag needs a gesture root above it, and the app's root Layout may not have one (#153).
// This one is only as big as the Slider, so it takes no touches meant for the Screen; inside
// another gesture root it simply defers to it. `style` lays it out, as the outermost View.
<GestureHandlerRootView style={[styles.gestureRoot, style]}>
<GestureDetector gesture={gesture}>
<View
accessible
{...adjustableRole}
aria-label={ariaLabel ?? field?.accessibilityProps["aria-label"]}
accessibilityHint={accessibilityHint ?? field?.accessibilityProps.accessibilityHint}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current}
aria-disabled={isDisabled}
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
onAccessibilityAction={handleAccessibilityAction}
onLayout={handleLayout}
testID={testID}
style={[styles.root, isDisabled ? styles.disabled : null]}
{...props}
>
<View style={styles.track}>
<Animated.View style={[styles.range, rangeStyle]} />
</View>
<Animated.View
testID={testID ? `${testID}-thumb` : undefined}
style={[styles.thumb, thumbStyle]}
/>
</View>
</GestureDetector>
</GestureHandlerRootView>
);
}
