diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts
index 2cf106849c..aab298990b 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -139,6 +139,10 @@ const pages = {
Switch: {
Switch: 'Switch/Switch',
},
+ Slider: {
+ Slider: 'Slider/Slider',
+ RangeSlider: 'Slider/RangeSlider',
+ },
TextInput: {
TextInput: {
source: 'TextInput/TextInput',
diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index 2af1eed385..a878a5cd86 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -35,6 +35,7 @@ import SearchbarExample from './Examples/SearchbarExample';
import SegmentedButtonMultiselectRealCase from './Examples/SegmentedButtons/SegmentedButtonMultiselectRealCase';
import SegmentedButtonRealCase from './Examples/SegmentedButtons/SegmentedButtonRealCase';
import SegmentedButtonExample from './Examples/SegmentedButtonsExample';
+import SliderExample from './Examples/SliderExample';
import SnackbarExample from './Examples/SnackbarExample';
import SurfaceExample from './Examples/SurfaceExample';
import SwitchExample from './Examples/SwitchExample';
@@ -78,6 +79,7 @@ export const mainExamples = {
Searchbar: SearchbarExample,
SegmentedButton: SegmentedButtonExample,
Snackbar: SnackbarExample,
+ Slider: SliderExample,
Surface: SurfaceExample,
Switch: SwitchExample,
Text: TextExample,
diff --git a/example/src/Examples/SliderExample.tsx b/example/src/Examples/SliderExample.tsx
new file mode 100644
index 0000000000..7a91694c16
--- /dev/null
+++ b/example/src/Examples/SliderExample.tsx
@@ -0,0 +1,338 @@
+import * as React from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import {
+ Card,
+ Divider,
+ RangeSlider,
+ Slider,
+ Surface,
+ Switch,
+ Text,
+ useTheme,
+} from 'react-native-paper';
+
+import ScreenWrapper from '../ScreenWrapper';
+
+const SliderExample = () => {
+ const theme = useTheme();
+
+ // State values for various slider configurations
+ const [continuousVal, setContinuousVal] = React.useState(45);
+ const [discreteVal, setDiscreteVal] = React.useState(30);
+ const [centeredVal, setCenteredVal] = React.useState(15);
+ const [rangeVals, setRangeVals] = React.useState<[number, number]>([25, 75]);
+ const [volumeVal, setVolumeVal] = React.useState(60);
+ const [brightnessVal, setBrightnessVal] = React.useState(80);
+ const [temperatureVal, setTemperatureVal] = React.useState(22);
+ const [alwaysVisibleVal, setAlwaysVisibleVal] = React.useState(24);
+ const [tertiaryVal, setTertiaryVal] = React.useState(70);
+ const [disableAll, setDisableAll] = React.useState(false);
+
+ // Custom tertiary theme for demonstrating theming capabilities
+ const tertiaryTheme = React.useMemo(
+ () => ({
+ colors: {
+ primary: theme.colors.tertiary,
+ onPrimary: theme.colors.onTertiary,
+ primaryContainer: theme.colors.tertiaryContainer,
+ secondary: theme.colors.tertiary,
+ surfaceContainerHighest: theme.colors.surfaceVariant,
+ },
+ }),
+ [theme]
+ );
+
+ return (
+
+ {/* Intro Header */}
+
+
+ Material Design 3 Sliders
+
+
+ Sliders let users make selections from a range of values. Conforms to
+ M3 specification with 16dp track, 4dp handle, 6dp gap, and stop
+ indicators.
+
+
+
+ {/* Global Disable Toggle */}
+
+ Disable all sliders
+
+
+
+
+
+ {/* 1. Continuous Slider */}
+
+
+
+
+ Allows selection of a continuous value along a smooth range.
+
+ `${Math.round(v)}%`}
+ />
+
+
+
+ {/* 2. Value Indicator Formats & Behaviors */}
+
+
+
+
+ Always visible label with temperature formatting (°C):
+
+ `${v}°C`}
+ disabled={disableAll}
+ />
+
+
+
+
+ {'Hidden value indicator (labelBehavior="gone"): '}
+
+
+
+
+
+ {/* 3. Discrete Slider with Steps & Tick Marks */}
+
+
+
+
+ Restricts selection to predetermined steps with M3 stop indicators.
+
+ `${v}`}
+ />
+
+
+
+ {/* 3. Centered Slider */}
+
+ 0 ? `+${centeredVal}` : centeredVal}`}
+ />
+
+
+ Active track originates from the center (origin: 0, range: -50 to
+ +50).
+
+ (v > 0 ? `+${v} dB` : `${v} dB`)}
+ />
+
+
+
+ {/* 4. Range Slider */}
+
+
+
+
+ Selects a minimum and maximum range between two independent handles.
+
+ `$${v}`}
+ />
+
+
+
+ {/* 5. With Icons */}
+
+
+
+
+ Volume control:
+
+ `${Math.round(v)}%`}
+ />
+
+
+
+
+ Display brightness:
+
+ `${Math.round(v)}%`}
+ />
+
+
+
+ {/* 7. Theming & Custom Colors */}
+
+
+
+
+ Custom theme with tertiary color roles and step ticks:
+
+ `${v}`}
+ />
+
+
+
+ {/* 8. Disabled State Showcase */}
+
+
+
+
+ Disabled Continuous:
+
+
+
+
+
+
+ Disabled Discrete with Ticks:
+
+
+
+
+
+
+ Disabled Range:
+
+
+
+
+
+ );
+};
+
+SliderExample.title = 'Slider';
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ paddingBottom: 40,
+ },
+ headerSurface: {
+ padding: 16,
+ borderRadius: 12,
+ marginBottom: 16,
+ },
+ title: {
+ fontWeight: '700',
+ marginBottom: 6,
+ },
+ subtitle: {
+ opacity: 0.8,
+ lineHeight: 20,
+ },
+ toggleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 8,
+ paddingHorizontal: 4,
+ },
+ divider: {
+ marginVertical: 12,
+ },
+ card: {
+ marginBottom: 16,
+ },
+ description: {
+ marginBottom: 12,
+ opacity: 0.75,
+ },
+ spacing: {
+ height: 16,
+ },
+ visibleSlider: {
+ marginTop: 48,
+ marginBottom: 8,
+ },
+});
+
+export default SliderExample;
diff --git a/src/components/Slider/RangeSlider.tsx b/src/components/Slider/RangeSlider.tsx
new file mode 100644
index 0000000000..d63a6e4761
--- /dev/null
+++ b/src/components/Slider/RangeSlider.tsx
@@ -0,0 +1,1183 @@
+import * as React from 'react';
+import {
+ type GestureResponderEvent,
+ type LayoutChangeEvent,
+ Platform,
+ StyleSheet,
+ type StyleProp,
+ View,
+ type ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+
+import { SliderTokens } from './tokens';
+import {
+ clamp,
+ getDefaultSliderColors,
+ getRatioFromValue,
+ getTickMarks,
+ getValueFromRatio,
+ snapToStep,
+} from './utils';
+import { useLocale } from '../../core/locale';
+import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { cornerFull } from '../../theme/tokens/sys/shape';
+import type { ThemeProp } from '../../theme/types';
+import Icon, { type IconSource } from '../Icon';
+import Text from '../Typography/Text';
+
+export type Props = {
+ /**
+ * Current range values of the slider [start, end] (controlled).
+ */
+ values?: [number, number];
+ /**
+ * Initial values when uncontrolled. Defaults to `[min, max]`.
+ */
+ defaultValues?: [number, number];
+ /**
+ * Minimum value of the range slider. Defaults to 0.
+ */
+ min?: number;
+ /**
+ * Maximum value of the range slider. Defaults to 100.
+ */
+ max?: number;
+ /**
+ * Step increment for discrete sliders.
+ */
+ step?: number;
+ /**
+ * Minimum separation distance between the two thumbs.
+ */
+ minSeparation?: number;
+ /**
+ * Whether to render stop indicators (tick marks) along the track.
+ * Defaults to `true` if `step` is provided, `false` otherwise.
+ */
+ showTickMarks?: boolean;
+ /**
+ * Behavior of the value indicator tooltips.
+ * - `'floating'`: appears when interacting (default)
+ * - `'visible'`: always visible
+ * - `'gone'`: never shown
+ */
+ labelBehavior?: 'floating' | 'visible' | 'gone';
+ /**
+ * Function to format the text inside the value indicators.
+ */
+ formatValueIndicator?: (value: number) => string;
+ /**
+ * Icon displayed before the slider track.
+ */
+ startIcon?: IconSource;
+ /**
+ * Icon displayed after the slider track.
+ */
+ endIcon?: IconSource;
+ /**
+ * Callback called when the values change during dragging.
+ */
+ onValueChange?: (values: [number, number]) => void;
+ /**
+ * Callback called when interaction begins.
+ */
+ onSlidingStart?: (values: [number, number]) => void;
+ /**
+ * Callback called when interaction ends.
+ */
+ onSlidingComplete?: (values: [number, number]) => void;
+ /**
+ * Disables interaction and renders the disabled visual state.
+ */
+ disabled?: boolean;
+ /**
+ * Custom height for the track (defaults to 16dp per M3 specs).
+ */
+ trackHeight?: number;
+ /**
+ * Custom gap size between thumb and track (defaults to 6dp per M3 specs).
+ */
+ thumbTrackGapSize?: number;
+ /**
+ * Custom color for the active track segment.
+ */
+ activeColor?: string;
+ /**
+ * Custom color for the inactive track segments.
+ */
+ inactiveColor?: string;
+ /**
+ * Custom color for the thumb handles.
+ */
+ thumbColor?: string;
+ style?: StyleProp;
+ testID?: string;
+ theme?: ThemeProp;
+ /**
+ * Accessibility label for the start thumb.
+ */
+ startAccessibilityLabel?: string;
+ /**
+ * Accessibility label for the end thumb.
+ */
+ endAccessibilityLabel?: string;
+};
+
+const {
+ trackHeight: DEFAULT_TRACK_HEIGHT,
+ trackCornerRadius: DEFAULT_TRACK_CORNER_RADIUS,
+ trackInsideCornerRadius: TRACK_INSIDE_CORNER_RADIUS,
+ thumbWidth: DEFAULT_THUMB_WIDTH,
+ thumbHeight: DEFAULT_THUMB_HEIGHT,
+ thumbCornerRadius: THUMB_CORNER_RADIUS,
+ thumbTrackGapSize: DEFAULT_GAP_SIZE,
+ stopIndicatorSize: STOP_INDICATOR_SIZE,
+ minTouchTargetSize: MIN_TOUCH_TARGET_SIZE,
+ stateLayerSize: STATE_LAYER_SIZE,
+ valueIndicatorHeight: VALUE_INDICATOR_HEIGHT,
+ valueIndicatorMinWidth: VALUE_INDICATOR_MIN_WIDTH,
+ valueIndicatorPaddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL,
+ valueIndicatorCornerRadius: VALUE_INDICATOR_CORNER_RADIUS,
+ valueIndicatorGap: VALUE_INDICATOR_GAP,
+ iconSize: DEFAULT_ICON_SIZE,
+ iconGap: DEFAULT_ICON_GAP,
+ disabledActiveTrackOpacity: DISABLED_ACTIVE_TRACK_OPACITY,
+ disabledInactiveTrackOpacity: DISABLED_INACTIVE_TRACK_OPACITY,
+ disabledHandleOpacity: DISABLED_HANDLE_OPACITY,
+ disabledStopIndicatorOpacity: DISABLED_STOP_INDICATOR_OPACITY,
+ disabledIconOpacity: DISABLED_ICON_OPACITY,
+} = SliderTokens;
+
+/**
+ * Material Design 3 Range Slider component.
+ *
+ * Allows users to select a range between two values with dual handles.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { RangeSlider } from 'react-native-paper';
+ *
+ * const Example = () => {
+ * const [range, setRange] = React.useState([20, 80]);
+ * return ;
+ * };
+ * ```
+ */
+const RangeSlider = ({
+ values: controlledValues,
+ defaultValues,
+ min = 0,
+ max = 100,
+ step,
+ minSeparation = 0,
+ showTickMarks = step !== undefined,
+ labelBehavior = 'floating',
+ formatValueIndicator,
+ startIcon,
+ endIcon,
+ onValueChange,
+ onSlidingStart,
+ onSlidingComplete,
+ disabled = false,
+ trackHeight = DEFAULT_TRACK_HEIGHT,
+ thumbTrackGapSize = DEFAULT_GAP_SIZE,
+ activeColor,
+ inactiveColor,
+ thumbColor,
+ style,
+ testID,
+ theme: themeOverrides,
+ startAccessibilityLabel = 'Minimum',
+ endAccessibilityLabel = 'Maximum',
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const reduceMotion = useReduceMotion();
+ const { direction } = useLocale();
+ const isRTL = direction === 'rtl';
+
+ const defaultSliderColors = React.useMemo(
+ () => getDefaultSliderColors(theme),
+ [theme]
+ );
+
+ const isControlled = controlledValues !== undefined;
+ const initialRange = defaultValues ?? [min, max];
+ const [internalValues, setInternalValues] = React.useState<[number, number]>([
+ snapToStep(initialRange[0], min, max, step),
+ snapToStep(initialRange[1], min, max, step),
+ ]);
+
+ const currentValues: [number, number] = React.useMemo(
+ () =>
+ isControlled
+ ? [
+ snapToStep(controlledValues[0], min, max, step),
+ snapToStep(controlledValues[1], min, max, step),
+ ]
+ : internalValues,
+ [isControlled, controlledValues, min, max, step, internalValues]
+ );
+
+ const [trackWidth, setTrackWidth] = React.useState(0);
+ const trackRef = React.useRef(null);
+ const trackPageX = React.useRef(0);
+
+ const activeThumbRef = React.useRef<'start' | 'end' | null>(null);
+ const isInteractingStartSV = useSharedValue(0);
+ const isInteractingEndSV = useSharedValue(0);
+ const [isStartFocused, setIsStartFocused] = React.useState(false);
+ const [isEndFocused, setIsEndFocused] = React.useState(false);
+ const [startIndicatorWidth, setStartIndicatorWidth] = React.useState(
+ VALUE_INDICATOR_MIN_WIDTH
+ );
+ const [endIndicatorWidth, setEndIndicatorWidth] = React.useState(
+ VALUE_INDICATOR_MIN_WIDTH
+ );
+
+ const handleStartIndicatorLayout = React.useCallback(
+ (e: LayoutChangeEvent) => {
+ const w = e.nativeEvent.layout.width;
+ if (w > 0 && Math.abs(w - startIndicatorWidth) > 0.5) {
+ setStartIndicatorWidth(w);
+ }
+ },
+ [startIndicatorWidth]
+ );
+
+ const handleEndIndicatorLayout = React.useCallback(
+ (e: LayoutChangeEvent) => {
+ const w = e.nativeEvent.layout.width;
+ if (w > 0 && Math.abs(w - endIndicatorWidth) > 0.5) {
+ setEndIndicatorWidth(w);
+ }
+ },
+ [endIndicatorWidth]
+ );
+
+ const latestCallbacks = React.useRef({
+ onValueChange,
+ onSlidingStart,
+ onSlidingComplete,
+ currentValues,
+ min,
+ max,
+ step,
+ minSeparation,
+ disabled,
+ isRTL,
+ isControlled,
+ });
+
+ React.useEffect(() => {
+ latestCallbacks.current = {
+ onValueChange,
+ onSlidingStart,
+ onSlidingComplete,
+ currentValues,
+ min,
+ max,
+ step,
+ minSeparation,
+ disabled,
+ isRTL,
+ isControlled,
+ };
+ });
+
+ const updateRangeFromX = React.useCallback(
+ (x: number) => {
+ const {
+ min: curMin,
+ max: curMax,
+ step: curStep,
+ minSeparation: curSeparation,
+ isRTL: curRTL,
+ onValueChange: cbValueChange,
+ currentValues: curVals,
+ isControlled: curControlled,
+ } = latestCallbacks.current;
+
+ const radius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS;
+ const travelWidth = Math.max(0, trackWidth - 2 * radius);
+ if (travelWidth <= 0 || !activeThumbRef.current) return;
+
+ let ratio = clamp((x - radius) / travelWidth, 0, 1);
+ if (curRTL) {
+ ratio = 1 - ratio;
+ }
+
+ const rawVal = getValueFromRatio(ratio, curMin, curMax, curStep);
+
+ let nextVals: [number, number];
+ if (activeThumbRef.current === 'start') {
+ const clampedStart = clamp(rawVal, curMin, curVals[1] - curSeparation);
+ nextVals = [clampedStart, curVals[1]];
+ } else {
+ const clampedEnd = clamp(rawVal, curVals[0] + curSeparation, curMax);
+ nextVals = [curVals[0], clampedEnd];
+ }
+
+ if (nextVals[0] !== curVals[0] || nextVals[1] !== curVals[1]) {
+ if (!curControlled) {
+ setInternalValues(nextVals);
+ }
+ cbValueChange?.(nextVals);
+ }
+ },
+ [trackHeight, trackWidth]
+ );
+
+ const handleStartShouldSetResponder = React.useCallback(
+ () => !disabled,
+ [disabled]
+ );
+
+ const handleMoveShouldSetResponder = React.useCallback(
+ () => !disabled,
+ [disabled]
+ );
+
+ const handleTerminationRequest = React.useCallback(() => false, []);
+
+ const handleResponderGrant = React.useCallback(
+ (evt: GestureResponderEvent) => {
+ if (disabled) return false;
+ const {
+ currentValues: curVals,
+ min: curMin,
+ max: curMax,
+ isRTL: curRTL,
+ } = latestCallbacks.current;
+
+ const pageX = evt.nativeEvent.pageX;
+ const locationX = evt.nativeEvent.locationX;
+ trackPageX.current = pageX - locationX;
+
+ const radius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS;
+ const travelWidth = Math.max(0, trackWidth - 2 * radius);
+
+ const startR = getRatioFromValue(curVals[0], curMin, curMax);
+ const endR = getRatioFromValue(curVals[1], curMin, curMax);
+
+ const startVisualR = curRTL ? 1 - startR : startR;
+ const endVisualR = curRTL ? 1 - endR : endR;
+
+ const startX = radius + startVisualR * travelWidth;
+ const endX = radius + endVisualR * travelWidth;
+
+ const distToStart = Math.abs(locationX - startX);
+ const distToEnd = Math.abs(locationX - endX);
+
+ const thumb = distToStart <= distToEnd ? 'start' : 'end';
+ activeThumbRef.current = thumb;
+
+ if (thumb === 'start') {
+ isInteractingStartSV.value = 1;
+ } else {
+ isInteractingEndSV.value = 1;
+ }
+
+ latestCallbacks.current.onSlidingStart?.(curVals);
+ updateRangeFromX(locationX);
+
+ if (trackRef.current) {
+ trackRef.current.measure((_x, _y, _width, _height, measuredPageX) => {
+ if (!isNaN(measuredPageX)) {
+ trackPageX.current = measuredPageX;
+ }
+ });
+ }
+
+ return true;
+ },
+ [
+ disabled,
+ trackHeight,
+ trackWidth,
+ isInteractingStartSV,
+ isInteractingEndSV,
+ updateRangeFromX,
+ ]
+ );
+
+ const handleResponderMove = React.useCallback(
+ (evt: GestureResponderEvent) => {
+ if (disabled) return;
+ const localX = evt.nativeEvent.pageX - trackPageX.current;
+ updateRangeFromX(localX);
+ },
+ [disabled, updateRangeFromX]
+ );
+
+ const handleResponderEnd = React.useCallback(() => {
+ isInteractingStartSV.value = 0;
+ isInteractingEndSV.value = 0;
+ activeThumbRef.current = null;
+ latestCallbacks.current.onSlidingComplete?.(
+ latestCallbacks.current.currentValues
+ );
+ }, [isInteractingStartSV, isInteractingEndSV]);
+
+ const onTrackLayout = React.useCallback((e: LayoutChangeEvent) => {
+ const { width } = e.nativeEvent.layout;
+ setTrackWidth(width);
+ if (trackRef.current) {
+ trackRef.current.measure((_x, _y, _width, _height, pageX) => {
+ if (!isNaN(pageX)) {
+ trackPageX.current = pageX;
+ }
+ });
+ }
+ }, []);
+
+ const handleStartAccessibilityAction = React.useCallback(
+ (event: { nativeEvent: { actionName: string } }) => {
+ if (disabled) return;
+ const stepVal = step ?? (max - min) / 100;
+ let next = currentValues[0];
+ if (event.nativeEvent.actionName === 'increment') {
+ next = currentValues[0] + stepVal;
+ } else if (event.nativeEvent.actionName === 'decrement') {
+ next = currentValues[0] - stepVal;
+ }
+ const clamped = clamp(
+ snapToStep(next, min, max, step),
+ min,
+ currentValues[1] - minSeparation
+ );
+ if (clamped !== currentValues[0]) {
+ const nextVals: [number, number] = [clamped, currentValues[1]];
+ if (!isControlled) {
+ setInternalValues(nextVals);
+ }
+ onValueChange?.(nextVals);
+ onSlidingComplete?.(nextVals);
+ }
+ },
+ [
+ disabled,
+ step,
+ max,
+ min,
+ currentValues,
+ minSeparation,
+ isControlled,
+ onValueChange,
+ onSlidingComplete,
+ ]
+ );
+
+ const handleEndAccessibilityAction = React.useCallback(
+ (event: { nativeEvent: { actionName: string } }) => {
+ if (disabled) return;
+ const stepVal = step ?? (max - min) / 100;
+ let next = currentValues[1];
+ if (event.nativeEvent.actionName === 'increment') {
+ next = currentValues[1] + stepVal;
+ } else if (event.nativeEvent.actionName === 'decrement') {
+ next = currentValues[1] - stepVal;
+ }
+ const clamped = clamp(
+ snapToStep(next, min, max, step),
+ currentValues[0] + minSeparation,
+ max
+ );
+ if (clamped !== currentValues[1]) {
+ const nextVals: [number, number] = [currentValues[0], clamped];
+ if (!isControlled) {
+ setInternalValues(nextVals);
+ }
+ onValueChange?.(nextVals);
+ onSlidingComplete?.(nextVals);
+ }
+ },
+ [
+ disabled,
+ step,
+ max,
+ min,
+ currentValues,
+ minSeparation,
+ isControlled,
+ onValueChange,
+ onSlidingComplete,
+ ]
+ );
+
+ const handleStartKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ const stepVal = step ?? (max - min) / 100;
+ let next = currentValues[0];
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
+ next = isRTL ? next - stepVal : next + stepVal;
+ e.preventDefault();
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
+ next = isRTL ? next + stepVal : next - stepVal;
+ e.preventDefault();
+ } else if (e.key === 'Home') {
+ next = min;
+ e.preventDefault();
+ } else if (e.key === 'End') {
+ next = currentValues[1] - minSeparation;
+ e.preventDefault();
+ } else if (e.key === 'PageUp') {
+ next = next + stepVal * 10;
+ e.preventDefault();
+ } else if (e.key === 'PageDown') {
+ next = next - stepVal * 10;
+ e.preventDefault();
+ }
+ const clamped = clamp(
+ snapToStep(next, min, max, step),
+ min,
+ currentValues[1] - minSeparation
+ );
+ if (clamped !== currentValues[0]) {
+ const nextVals: [number, number] = [clamped, currentValues[1]];
+ setInternalValues(nextVals);
+ onValueChange?.(nextVals);
+ onSlidingComplete?.(nextVals);
+ }
+ },
+ [
+ disabled,
+ step,
+ max,
+ min,
+ currentValues,
+ isRTL,
+ minSeparation,
+ onValueChange,
+ onSlidingComplete,
+ ]
+ );
+
+ const handleEndKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ const stepVal = step ?? (max - min) / 100;
+ let next = currentValues[1];
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
+ next = isRTL ? next - stepVal : next + stepVal;
+ e.preventDefault();
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
+ next = isRTL ? next + stepVal : next - stepVal;
+ e.preventDefault();
+ } else if (e.key === 'Home') {
+ next = currentValues[0] + minSeparation;
+ e.preventDefault();
+ } else if (e.key === 'End') {
+ next = max;
+ e.preventDefault();
+ } else if (e.key === 'PageUp') {
+ next = next + stepVal * 10;
+ e.preventDefault();
+ } else if (e.key === 'PageDown') {
+ next = next - stepVal * 10;
+ e.preventDefault();
+ }
+ const clamped = clamp(
+ snapToStep(next, min, max, step),
+ currentValues[0] + minSeparation,
+ max
+ );
+ if (clamped !== currentValues[1]) {
+ const nextVals: [number, number] = [currentValues[0], clamped];
+ setInternalValues(nextVals);
+ onValueChange?.(nextVals);
+ onSlidingComplete?.(nextVals);
+ }
+ },
+ [
+ disabled,
+ step,
+ max,
+ min,
+ currentValues,
+ isRTL,
+ minSeparation,
+ onValueChange,
+ onSlidingComplete,
+ ]
+ );
+
+ const trackCornerRadius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS;
+ const travelWidth = Math.max(0, trackWidth - 2 * trackCornerRadius);
+
+ const startRatio = getRatioFromValue(currentValues[0], min, max);
+ const endRatio = getRatioFromValue(currentValues[1], min, max);
+
+ const visualStartRatio = isRTL ? 1 - startRatio : startRatio;
+ const visualEndRatio = isRTL ? 1 - endRatio : endRatio;
+
+ const startThumbCenter = trackCornerRadius + visualStartRatio * travelWidth;
+ const endThumbCenter = trackCornerRadius + visualEndRatio * travelWidth;
+
+ const gap = thumbTrackGapSize;
+ const halfThumb = DEFAULT_THUMB_WIDTH / 2;
+
+ // Active track is between the two thumbs (with gap on each side)
+ const leftEdge = Math.min(startThumbCenter, endThumbCenter);
+ const rightEdge = Math.max(startThumbCenter, endThumbCenter);
+
+ const activeLeft = leftEdge + halfThumb + gap;
+ const activeWidth = Math.max(0, rightEdge - halfThumb - gap - activeLeft);
+
+ // Inactive segments on the left and right
+ const inactiveLeftWidth = Math.max(0, leftEdge - halfThumb - gap);
+ const inactiveRightLeft = rightEdge + halfThumb + gap;
+ const inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft);
+
+ const effectiveActiveTrackColor = disabled
+ ? defaultSliderColors.disabledActiveTrackColor
+ : (activeColor ?? defaultSliderColors.activeTrackColor);
+
+ const effectiveInactiveTrackColor = disabled
+ ? defaultSliderColors.disabledInactiveTrackColor
+ : (inactiveColor ?? defaultSliderColors.inactiveTrackColor);
+
+ const effectiveThumbColor = disabled
+ ? defaultSliderColors.disabledHandleColor
+ : (thumbColor ?? defaultSliderColors.handleColor);
+
+ const effectiveIconColor = disabled
+ ? defaultSliderColors.disabledIconColor
+ : defaultSliderColors.iconColor;
+
+ const animDuration = reduceMotion ? 0 : 150;
+
+ const startThumbAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scaleX: withTiming(isInteractingStartSV.value ? 1.5 : 1, {
+ duration: animDuration,
+ }),
+ },
+ ],
+ }));
+
+ const endThumbAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scaleX: withTiming(isInteractingEndSV.value ? 1.5 : 1, {
+ duration: animDuration,
+ }),
+ },
+ ],
+ }));
+
+ const stateLayerStartAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: withTiming(
+ isInteractingStartSV.value ? 0.12 : isStartFocused ? 0.1 : 0,
+ {
+ duration: animDuration,
+ }
+ ),
+ transform: [
+ {
+ scale: withTiming(
+ isInteractingStartSV.value || isStartFocused ? 1 : 0.6,
+ {
+ duration: animDuration,
+ }
+ ),
+ },
+ ],
+ }));
+
+ const stateLayerEndAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: withTiming(
+ isInteractingEndSV.value ? 0.12 : isEndFocused ? 0.1 : 0,
+ {
+ duration: animDuration,
+ }
+ ),
+ transform: [
+ {
+ scale: withTiming(isInteractingEndSV.value || isEndFocused ? 1 : 0.6, {
+ duration: animDuration,
+ }),
+ },
+ ],
+ }));
+
+ const valueIndicatorStartAnimatedStyle = useAnimatedStyle(() => {
+ if (labelBehavior === 'visible') {
+ return { opacity: 1, transform: [{ scale: 1 }] };
+ }
+ if (labelBehavior === 'gone') {
+ return { opacity: 0, transform: [{ scale: 0 }] };
+ }
+ const show = isInteractingStartSV.value === 1 || isStartFocused;
+ return {
+ opacity: withTiming(show ? 1 : 0, { duration: animDuration }),
+ transform: [
+ {
+ scale: withTiming(show ? 1 : 0.8, { duration: animDuration }),
+ },
+ ],
+ };
+ });
+
+ const valueIndicatorEndAnimatedStyle = useAnimatedStyle(() => {
+ if (labelBehavior === 'visible') {
+ return { opacity: 1, transform: [{ scale: 1 }] };
+ }
+ if (labelBehavior === 'gone') {
+ return { opacity: 0, transform: [{ scale: 0 }] };
+ }
+ const show = isInteractingEndSV.value === 1 || isEndFocused;
+ return {
+ opacity: withTiming(show ? 1 : 0, { duration: animDuration }),
+ transform: [
+ {
+ scale: withTiming(show ? 1 : 0.8, { duration: animDuration }),
+ },
+ ],
+ };
+ });
+
+ const tickMarks = React.useMemo(
+ () => (showTickMarks ? getTickMarks(min, max, step) : []),
+ [showTickMarks, min, max, step]
+ );
+
+ const startFormatted = formatValueIndicator
+ ? formatValueIndicator(currentValues[0])
+ : String(currentValues[0]);
+
+ const endFormatted = formatValueIndicator
+ ? formatValueIndicator(currentValues[1])
+ : String(currentValues[1]);
+
+ return (
+
+ {/* Leading Icon */}
+ {startIcon ? (
+
+
+
+ ) : null}
+
+ {/* Main Track & Dual Thumbs Area */}
+
+ {/* Inactive Left Track */}
+ {trackWidth > 0 && inactiveLeftWidth > 0 ? (
+
+ ) : null}
+
+ {/* Active Middle Track */}
+ {trackWidth > 0 && activeWidth > 0 ? (
+
+ ) : null}
+
+ {/* Inactive Right Track */}
+ {trackWidth > 0 && inactiveRightWidth > 0 ? (
+
+ ) : null}
+
+ {/* Stop Indicators (Tick Marks) */}
+ {trackWidth > 0 && tickMarks.length > 0
+ ? tickMarks.map((tickRatio, idx) => {
+ const tickVisualRatio = isRTL ? 1 - tickRatio : tickRatio;
+ const tickPos = trackCornerRadius + tickVisualRatio * travelWidth;
+ const isActiveTick =
+ tickRatio >= startRatio && tickRatio <= endRatio;
+
+ // Don't render ticks inside thumb gaps
+ const distToStartThumb = Math.abs(tickPos - startThumbCenter);
+ const distToEndThumb = Math.abs(tickPos - endThumbCenter);
+ if (
+ distToStartThumb < halfThumb + gap ||
+ distToEndThumb < halfThumb + gap
+ ) {
+ return null;
+ }
+
+ const tickColor = isActiveTick
+ ? defaultSliderColors.stopIndicatorActiveColor
+ : defaultSliderColors.stopIndicatorInactiveColor;
+
+ return (
+
+ );
+ })
+ : null}
+
+ {/* Start State Layer & Thumb */}
+
+ setIsStartFocused(true),
+ onBlur: () => setIsStartFocused(false),
+ onKeyDown: handleStartKeyDown,
+ tabIndex: disabled ? -1 : 0,
+ }
+ : {})}
+ />
+ {labelBehavior !== 'gone' ? (
+
+
+
+ {startFormatted}
+
+
+
+ ) : null}
+
+ {/* End State Layer & Thumb */}
+
+ setIsEndFocused(true),
+ onBlur: () => setIsEndFocused(false),
+ onKeyDown: handleEndKeyDown,
+ tabIndex: disabled ? -1 : 0,
+ }
+ : {})}
+ />
+ {labelBehavior !== 'gone' ? (
+
+
+
+ {endFormatted}
+
+
+
+ ) : null}
+
+
+ {/* Trailing Icon */}
+ {endIcon ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+// Web-only style; not in StyleSheet because `outline` is outside ViewStyle.
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const webNoOutline = { outline: 'none' } as unknown as ViewStyle;
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ width: '100%',
+ minHeight: MIN_TOUCH_TARGET_SIZE,
+ },
+ touchArea: {
+ flex: 1,
+ height: MIN_TOUCH_TARGET_SIZE,
+ justifyContent: 'center',
+ position: 'relative',
+ overflow: 'visible',
+ },
+ segment: {
+ position: 'absolute',
+ pointerEvents: 'none',
+ },
+ inactiveLeftSegment: {
+ left: 0,
+ },
+ thumb: {
+ position: 'absolute',
+ width: DEFAULT_THUMB_WIDTH,
+ height: DEFAULT_THUMB_HEIGHT,
+ borderRadius: THUMB_CORNER_RADIUS,
+ elevation: 1,
+ pointerEvents: 'none',
+ },
+ stateLayer: {
+ position: 'absolute',
+ width: STATE_LAYER_SIZE,
+ height: STATE_LAYER_SIZE,
+ borderRadius: cornerFull,
+ pointerEvents: 'none',
+ },
+ tickMark: {
+ position: 'absolute',
+ width: STOP_INDICATOR_SIZE,
+ height: STOP_INDICATOR_SIZE,
+ borderRadius: STOP_INDICATOR_SIZE / 2,
+ pointerEvents: 'none',
+ },
+ valueIndicatorAnchor: {
+ position: 'absolute',
+ top:
+ (MIN_TOUCH_TARGET_SIZE - DEFAULT_THUMB_HEIGHT) / 2 -
+ VALUE_INDICATOR_GAP -
+ VALUE_INDICATOR_HEIGHT,
+ height: VALUE_INDICATOR_HEIGHT,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ valueIndicator: {
+ flexDirection: 'row',
+ height: VALUE_INDICATOR_HEIGHT,
+ minWidth: VALUE_INDICATOR_MIN_WIDTH,
+ paddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL,
+ borderRadius: VALUE_INDICATOR_CORNER_RADIUS,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ valueIndicatorText: {
+ fontWeight: '500',
+ textAlign: 'center',
+ flexShrink: 0,
+ },
+ iconWrap: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ startIconWrap: {
+ marginRight: DEFAULT_ICON_GAP,
+ },
+ endIconWrap: {
+ marginLeft: DEFAULT_ICON_GAP,
+ },
+ disabledActiveTrack: {
+ opacity: DISABLED_ACTIVE_TRACK_OPACITY,
+ },
+ disabledInactiveTrack: {
+ opacity: DISABLED_INACTIVE_TRACK_OPACITY,
+ },
+ disabledHandle: {
+ opacity: DISABLED_HANDLE_OPACITY,
+ },
+ disabledStopIndicator: {
+ opacity: DISABLED_STOP_INDICATOR_OPACITY,
+ },
+ disabledIcon: {
+ opacity: DISABLED_ICON_OPACITY,
+ },
+});
+
+export default RangeSlider;
diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx
new file mode 100644
index 0000000000..a09f259ffd
--- /dev/null
+++ b/src/components/Slider/Slider.tsx
@@ -0,0 +1,996 @@
+import * as React from 'react';
+import {
+ type GestureResponderEvent,
+ type LayoutChangeEvent,
+ Platform,
+ StyleSheet,
+ type StyleProp,
+ View,
+ type ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+
+import { SliderTokens } from './tokens';
+import {
+ clamp,
+ getDefaultSliderColors,
+ getRatioFromValue,
+ getTickMarks,
+ getValueFromRatio,
+ snapToStep,
+} from './utils';
+import { useLocale } from '../../core/locale';
+import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { cornerFull } from '../../theme/tokens/sys/shape';
+import type { ThemeProp } from '../../theme/types';
+import Icon, { type IconSource } from '../Icon';
+import Text from '../Typography/Text';
+
+export type Props = {
+ /**
+ * Current value of the slider (controlled).
+ */
+ value?: number;
+ /**
+ * Initial value when uncontrolled. Defaults to `min`.
+ */
+ defaultValue?: number;
+ /**
+ * Minimum value of the slider. Defaults to 0.
+ */
+ min?: number;
+ /**
+ * Maximum value of the slider. Defaults to 100.
+ */
+ max?: number;
+ /**
+ * Step increment for discrete sliders.
+ */
+ step?: number;
+ /**
+ * Whether to render stop indicators (tick marks) along the track.
+ * Defaults to `true` if `step` is provided, `false` otherwise.
+ */
+ showTickMarks?: boolean;
+ /**
+ * Whether this is a centered slider, where the active track originates from the center or custom origin.
+ */
+ centered?: boolean;
+ /**
+ * Custom origin value for centered sliders. Defaults to `(min + max) / 2` when `centered` is true.
+ */
+ origin?: number;
+ /**
+ * Behavior of the value indicator tooltip.
+ * - `'floating'`: appears when interacting (default)
+ * - `'visible'`: always visible
+ * - `'gone'`: never shown
+ */
+ labelBehavior?: 'floating' | 'visible' | 'gone';
+ /**
+ * Function to format the text inside the value indicator.
+ */
+ formatValueIndicator?: (value: number) => string;
+ /**
+ * Icon displayed before the slider track.
+ */
+ startIcon?: IconSource;
+ /**
+ * Icon displayed after the slider track.
+ */
+ endIcon?: IconSource;
+ /**
+ * Callback called when the value changes during dragging.
+ */
+ onValueChange?: (value: number) => void;
+ /**
+ * Callback called when interaction begins.
+ */
+ onSlidingStart?: (value: number) => void;
+ /**
+ * Callback called when interaction ends.
+ */
+ onSlidingComplete?: (value: number) => void;
+ /**
+ * Disables interaction and renders the disabled visual state.
+ */
+ disabled?: boolean;
+ /**
+ * Custom height for the track (defaults to 16dp per M3 specs).
+ */
+ trackHeight?: number;
+ /**
+ * Custom gap size between thumb and track (defaults to 6dp per M3 specs).
+ */
+ thumbTrackGapSize?: number;
+ /**
+ * Custom color for the active track.
+ */
+ activeColor?: string;
+ /**
+ * Custom color for the inactive track.
+ */
+ inactiveColor?: string;
+ /**
+ * Custom color for the thumb handle.
+ */
+ thumbColor?: string;
+ style?: StyleProp;
+ testID?: string;
+ theme?: ThemeProp;
+ /**
+ * Accessibility label for the slider.
+ */
+ 'aria-label'?: string;
+ accessibilityLabel?: string;
+};
+
+const {
+ trackHeight: DEFAULT_TRACK_HEIGHT,
+ trackCornerRadius: DEFAULT_TRACK_CORNER_RADIUS,
+ trackInsideCornerRadius: TRACK_INSIDE_CORNER_RADIUS,
+ thumbWidth: DEFAULT_THUMB_WIDTH,
+ thumbHeight: DEFAULT_THUMB_HEIGHT,
+ thumbCornerRadius: THUMB_CORNER_RADIUS,
+ thumbTrackGapSize: DEFAULT_GAP_SIZE,
+ stopIndicatorSize: STOP_INDICATOR_SIZE,
+ minTouchTargetSize: MIN_TOUCH_TARGET_SIZE,
+ stateLayerSize: STATE_LAYER_SIZE,
+ valueIndicatorHeight: VALUE_INDICATOR_HEIGHT,
+ valueIndicatorMinWidth: VALUE_INDICATOR_MIN_WIDTH,
+ valueIndicatorPaddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL,
+ valueIndicatorCornerRadius: VALUE_INDICATOR_CORNER_RADIUS,
+ valueIndicatorGap: VALUE_INDICATOR_GAP,
+ iconSize: DEFAULT_ICON_SIZE,
+ iconGap: DEFAULT_ICON_GAP,
+ disabledActiveTrackOpacity: DISABLED_ACTIVE_TRACK_OPACITY,
+ disabledInactiveTrackOpacity: DISABLED_INACTIVE_TRACK_OPACITY,
+ disabledHandleOpacity: DISABLED_HANDLE_OPACITY,
+ disabledStopIndicatorOpacity: DISABLED_STOP_INDICATOR_OPACITY,
+ disabledIconOpacity: DISABLED_ICON_OPACITY,
+} = SliderTokens;
+
+/**
+ * Material Design 3 Slider component.
+ *
+ * Sliders let users make selections from a range of values.
+ * Supports continuous, discrete (stops/ticks), centered, and custom origin configurations.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { Slider } from 'react-native-paper';
+ *
+ * const Example = () => {
+ * const [value, setValue] = React.useState(50);
+ * return ;
+ * };
+ * ```
+ */
+const Slider = ({
+ value: controlledValue,
+ defaultValue,
+ min = 0,
+ max = 100,
+ step,
+ showTickMarks = step !== undefined,
+ centered = false,
+ origin: customOrigin,
+ labelBehavior = 'floating',
+ formatValueIndicator,
+ startIcon,
+ endIcon,
+ onValueChange,
+ onSlidingStart,
+ onSlidingComplete,
+ disabled = false,
+ trackHeight = DEFAULT_TRACK_HEIGHT,
+ thumbTrackGapSize = DEFAULT_GAP_SIZE,
+ activeColor,
+ inactiveColor,
+ thumbColor,
+ style,
+ testID,
+ theme: themeOverrides,
+ 'aria-label': ariaLabelProp,
+ accessibilityLabel: accessibilityLabelProp,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const reduceMotion = useReduceMotion();
+ const { direction } = useLocale();
+ const isRTL = direction === 'rtl';
+
+ const defaultSliderColors = React.useMemo(
+ () => getDefaultSliderColors(theme),
+ [theme]
+ );
+
+ const isControlled = controlledValue !== undefined;
+ const initialVal = controlledValue ?? defaultValue ?? min;
+ const [internalValue, setInternalValue] = React.useState(
+ snapToStep(initialVal, min, max, step)
+ );
+
+ const currentValue = isControlled
+ ? snapToStep(controlledValue, min, max, step)
+ : internalValue;
+
+ const [trackWidth, setTrackWidth] = React.useState(0);
+ const trackRef = React.useRef(null);
+ const trackPageX = React.useRef(0);
+
+ const isInteractingSV = useSharedValue(0);
+ const [isFocused, setIsFocused] = React.useState(false);
+ const [indicatorWidth, setIndicatorWidth] = React.useState(
+ VALUE_INDICATOR_MIN_WIDTH
+ );
+
+ const handleIndicatorLayout = React.useCallback(
+ (e: LayoutChangeEvent) => {
+ const w = e.nativeEvent.layout.width;
+ if (w > 0 && Math.abs(w - indicatorWidth) > 0.5) {
+ setIndicatorWidth(w);
+ }
+ },
+ [indicatorWidth]
+ );
+
+ const latestCallbacks = React.useRef({
+ onValueChange,
+ onSlidingStart,
+ onSlidingComplete,
+ currentValue,
+ min,
+ max,
+ step,
+ disabled,
+ isRTL,
+ isControlled,
+ });
+
+ React.useEffect(() => {
+ latestCallbacks.current = {
+ onValueChange,
+ onSlidingStart,
+ onSlidingComplete,
+ currentValue,
+ min,
+ max,
+ step,
+ disabled,
+ isRTL,
+ isControlled,
+ };
+ });
+
+ const updateValueFromX = React.useCallback(
+ (x: number) => {
+ const {
+ min: curMin,
+ max: curMax,
+ step: curStep,
+ isRTL: curRTL,
+ onValueChange: cbValueChange,
+ currentValue: curVal,
+ isControlled: curControlled,
+ } = latestCallbacks.current;
+
+ const radius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS;
+ const travelWidth = Math.max(0, trackWidth - 2 * radius);
+ if (travelWidth <= 0) return;
+
+ let ratio = clamp((x - radius) / travelWidth, 0, 1);
+ if (curRTL) {
+ ratio = 1 - ratio;
+ }
+
+ const nextVal = getValueFromRatio(ratio, curMin, curMax, curStep);
+ if (nextVal !== curVal) {
+ if (!curControlled) {
+ setInternalValue(nextVal);
+ }
+ cbValueChange?.(nextVal);
+ }
+ },
+ [trackHeight, trackWidth]
+ );
+
+ const handleStartShouldSetResponder = React.useCallback(
+ () => !disabled,
+ [disabled]
+ );
+
+ const handleMoveShouldSetResponder = React.useCallback(
+ () => !disabled,
+ [disabled]
+ );
+
+ const handleTerminationRequest = React.useCallback(() => false, []);
+
+ const handleResponderGrant = React.useCallback(
+ (evt: GestureResponderEvent) => {
+ if (disabled) return false;
+ isInteractingSV.value = 1;
+ onSlidingStart?.(currentValue);
+
+ const pageX = evt.nativeEvent.pageX;
+ const locationX = evt.nativeEvent.locationX;
+ trackPageX.current = pageX - locationX;
+ updateValueFromX(locationX);
+
+ if (trackRef.current) {
+ trackRef.current.measure((_x, _y, _width, _height, measuredPageX) => {
+ if (!isNaN(measuredPageX)) {
+ trackPageX.current = measuredPageX;
+ }
+ });
+ }
+ return true;
+ },
+ [disabled, isInteractingSV, onSlidingStart, currentValue, updateValueFromX]
+ );
+
+ const handleResponderMove = React.useCallback(
+ (evt: GestureResponderEvent) => {
+ if (disabled) return;
+ const localX = evt.nativeEvent.pageX - trackPageX.current;
+ updateValueFromX(localX);
+ },
+ [disabled, updateValueFromX]
+ );
+
+ const handleResponderEnd = React.useCallback(() => {
+ isInteractingSV.value = 0;
+ onSlidingComplete?.(currentValue);
+ }, [isInteractingSV, onSlidingComplete, currentValue]);
+
+ const onTrackLayout = React.useCallback((e: LayoutChangeEvent) => {
+ const { width } = e.nativeEvent.layout;
+ setTrackWidth(width);
+ if (trackRef.current) {
+ trackRef.current.measure((_x, _y, _width, _height, pageX) => {
+ trackPageX.current = pageX;
+ });
+ }
+ }, []);
+
+ const handleAccessibilityAction = React.useCallback(
+ (event: { nativeEvent: { actionName: string } }) => {
+ if (disabled) return;
+ const stepVal = step ?? (max - min) / 100;
+ let next = currentValue;
+ if (event.nativeEvent.actionName === 'increment') {
+ next = currentValue + stepVal;
+ } else if (event.nativeEvent.actionName === 'decrement') {
+ next = currentValue - stepVal;
+ }
+ const clamped = snapToStep(next, min, max, step);
+ if (clamped !== currentValue) {
+ if (!isControlled) {
+ setInternalValue(clamped);
+ }
+ onValueChange?.(clamped);
+ onSlidingComplete?.(clamped);
+ }
+ },
+ [
+ disabled,
+ step,
+ max,
+ min,
+ currentValue,
+ isControlled,
+ onValueChange,
+ onSlidingComplete,
+ ]
+ );
+
+ const handleKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ const stepVal = step ?? (max - min) / 100;
+ let next = currentValue;
+
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
+ next = isRTL ? currentValue - stepVal : currentValue + stepVal;
+ e.preventDefault();
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
+ next = isRTL ? currentValue + stepVal : currentValue - stepVal;
+ e.preventDefault();
+ } else if (e.key === 'Home') {
+ next = min;
+ e.preventDefault();
+ } else if (e.key === 'End') {
+ next = max;
+ e.preventDefault();
+ } else if (e.key === 'PageUp') {
+ next = currentValue + stepVal * 10;
+ e.preventDefault();
+ } else if (e.key === 'PageDown') {
+ next = currentValue - stepVal * 10;
+ e.preventDefault();
+ }
+
+ const clamped = snapToStep(next, min, max, step);
+ if (clamped !== currentValue) {
+ setInternalValue(clamped);
+ onValueChange?.(clamped);
+ onSlidingComplete?.(clamped);
+ }
+ },
+ [
+ disabled,
+ step,
+ max,
+ min,
+ currentValue,
+ isRTL,
+ onValueChange,
+ onSlidingComplete,
+ ]
+ );
+
+ const trackCornerRadius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS;
+ const travelWidth = Math.max(0, trackWidth - 2 * trackCornerRadius);
+
+ const ratio = getRatioFromValue(currentValue, min, max);
+ const visualRatio = isRTL ? 1 - ratio : ratio;
+ const thumbCenter = trackCornerRadius + visualRatio * travelWidth;
+
+ const effectiveOrigin = centered
+ ? (customOrigin ?? (min + max) / 2)
+ : undefined;
+ const originRatio =
+ effectiveOrigin !== undefined
+ ? getRatioFromValue(effectiveOrigin, min, max)
+ : 0;
+ const visualOriginRatio = isRTL ? 1 - originRatio : originRatio;
+ const originCenter = trackCornerRadius + visualOriginRatio * travelWidth;
+
+ const effectiveActiveTrackColor = disabled
+ ? defaultSliderColors.disabledActiveTrackColor
+ : (activeColor ?? defaultSliderColors.activeTrackColor);
+
+ const effectiveInactiveTrackColor = disabled
+ ? defaultSliderColors.disabledInactiveTrackColor
+ : (inactiveColor ?? defaultSliderColors.inactiveTrackColor);
+
+ const effectiveThumbColor = disabled
+ ? defaultSliderColors.disabledHandleColor
+ : (thumbColor ?? defaultSliderColors.handleColor);
+
+ const effectiveIconColor = disabled
+ ? defaultSliderColors.disabledIconColor
+ : defaultSliderColors.iconColor;
+
+ const animDuration = reduceMotion ? 0 : 150;
+
+ const stateLayerAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: withTiming(isInteractingSV.value ? 0.12 : isFocused ? 0.1 : 0, {
+ duration: animDuration,
+ }),
+ transform: [
+ {
+ scale: withTiming(isInteractingSV.value || isFocused ? 1 : 0.6, {
+ duration: animDuration,
+ }),
+ },
+ ],
+ }));
+
+ const thumbAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scaleX: withTiming(isInteractingSV.value ? 1.5 : 1, {
+ duration: animDuration,
+ }),
+ },
+ ],
+ }));
+
+ const valueIndicatorAnimatedStyle = useAnimatedStyle(() => {
+ if (labelBehavior === 'visible') {
+ return { opacity: 1, transform: [{ scale: 1 }] };
+ }
+ if (labelBehavior === 'gone') {
+ return { opacity: 0, transform: [{ scale: 0 }] };
+ }
+ const show = isInteractingSV.value === 1 || isFocused;
+ return {
+ opacity: withTiming(show ? 1 : 0, { duration: animDuration }),
+ transform: [
+ {
+ scale: withTiming(show ? 1 : 0.8, { duration: animDuration }),
+ },
+ ],
+ };
+ });
+
+ const gap = thumbTrackGapSize;
+ const halfThumb = DEFAULT_THUMB_WIDTH / 2;
+
+ let activeLeft = 0;
+ let activeWidth = 0;
+ let activeBorderRadius: ViewStyle = {};
+
+ let inactiveLeftWidth = 0;
+ let inactiveLeftRadius: ViewStyle = {};
+
+ let inactiveRightLeft = 0;
+ let inactiveRightWidth = 0;
+ let inactiveRightRadius: ViewStyle = {};
+
+ if (effectiveOrigin !== undefined) {
+ if (thumbCenter >= originCenter) {
+ activeLeft = originCenter;
+ activeWidth = Math.max(0, thumbCenter - originCenter - halfThumb - gap);
+ activeBorderRadius = {
+ borderRadius: TRACK_INSIDE_CORNER_RADIUS,
+ };
+
+ const inactiveLeftEnd =
+ thumbCenter - halfThumb - gap < originCenter
+ ? Math.max(0, thumbCenter - halfThumb - gap)
+ : originCenter;
+ inactiveLeftWidth = inactiveLeftEnd;
+ inactiveLeftRadius = {
+ borderTopLeftRadius: trackCornerRadius,
+ borderBottomLeftRadius: trackCornerRadius,
+ borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ };
+
+ inactiveRightLeft = thumbCenter + halfThumb + gap;
+ inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft);
+ inactiveRightRadius = {
+ borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderTopRightRadius: trackCornerRadius,
+ borderBottomRightRadius: trackCornerRadius,
+ };
+ } else {
+ activeLeft = thumbCenter + halfThumb + gap;
+ activeWidth = Math.max(0, originCenter - activeLeft);
+ activeBorderRadius = {
+ borderRadius: TRACK_INSIDE_CORNER_RADIUS,
+ };
+
+ inactiveLeftWidth = Math.max(0, thumbCenter - halfThumb - gap);
+ inactiveLeftRadius = {
+ borderTopLeftRadius: trackCornerRadius,
+ borderBottomLeftRadius: trackCornerRadius,
+ borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ };
+
+ const inactiveRightStart =
+ thumbCenter + halfThumb + gap > originCenter
+ ? thumbCenter + halfThumb + gap
+ : originCenter;
+ inactiveRightLeft = inactiveRightStart;
+ inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft);
+ inactiveRightRadius = {
+ borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderTopRightRadius: trackCornerRadius,
+ borderBottomRightRadius: trackCornerRadius,
+ };
+ }
+ } else {
+ if (!isRTL) {
+ activeLeft = 0;
+ activeWidth = Math.max(0, thumbCenter - halfThumb - gap);
+ activeBorderRadius = {
+ borderTopLeftRadius: trackCornerRadius,
+ borderBottomLeftRadius: trackCornerRadius,
+ borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ };
+
+ inactiveRightLeft = thumbCenter + halfThumb + gap;
+ inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft);
+ inactiveRightRadius = {
+ borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderTopRightRadius: trackCornerRadius,
+ borderBottomRightRadius: trackCornerRadius,
+ };
+ } else {
+ inactiveLeftWidth = Math.max(0, thumbCenter - halfThumb - gap);
+ inactiveLeftRadius = {
+ borderTopLeftRadius: trackCornerRadius,
+ borderBottomLeftRadius: trackCornerRadius,
+ borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS,
+ };
+
+ activeLeft = thumbCenter + halfThumb + gap;
+ activeWidth = Math.max(0, trackWidth - activeLeft);
+ activeBorderRadius = {
+ borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS,
+ borderTopRightRadius: trackCornerRadius,
+ borderBottomRightRadius: trackCornerRadius,
+ };
+ }
+ }
+
+ const tickMarks = React.useMemo(
+ () => (showTickMarks ? getTickMarks(min, max, step) : []),
+ [showTickMarks, min, max, step]
+ );
+
+ const formattedLabel = formatValueIndicator
+ ? formatValueIndicator(currentValue)
+ : String(currentValue);
+
+ const accessibilityLabel =
+ ariaLabelProp ?? accessibilityLabelProp ?? 'Slider';
+
+ const inactiveLeftTrackDynamicStyle = {
+ height: trackHeight,
+ width: inactiveLeftWidth,
+ backgroundColor: effectiveInactiveTrackColor,
+ ...inactiveLeftRadius,
+ };
+
+ const activeTrackDynamicStyle = {
+ height: trackHeight,
+ left: activeLeft,
+ width: activeWidth,
+ backgroundColor: effectiveActiveTrackColor,
+ ...activeBorderRadius,
+ };
+
+ const inactiveRightTrackDynamicStyle = {
+ height: trackHeight,
+ left: inactiveRightLeft,
+ width: inactiveRightWidth,
+ backgroundColor: effectiveInactiveTrackColor,
+ ...inactiveRightRadius,
+ };
+
+ const stateLayerDynamicStyle = {
+ left: thumbCenter - STATE_LAYER_SIZE / 2,
+ top: (MIN_TOUCH_TARGET_SIZE - STATE_LAYER_SIZE) / 2,
+ backgroundColor: defaultSliderColors.stateLayerColor,
+ };
+
+ const thumbDynamicStyle = {
+ left: thumbCenter - DEFAULT_THUMB_WIDTH / 2,
+ top: (MIN_TOUCH_TARGET_SIZE - DEFAULT_THUMB_HEIGHT) / 2,
+ backgroundColor: effectiveThumbColor,
+ };
+
+ const valueIndicatorAnchorDynamicStyle = {
+ left: thumbCenter - indicatorWidth / 2,
+ };
+
+ const valueIndicatorDynamicStyle = {
+ backgroundColor: defaultSliderColors.valueIndicatorContainerColor,
+ };
+
+ const valueIndicatorTextDynamicStyle = {
+ color: defaultSliderColors.valueIndicatorTextColor,
+ };
+
+ return (
+ setIsFocused(true),
+ onBlur: () => setIsFocused(false),
+ }
+ : {})}
+ accessible
+ accessibilityRole="adjustable"
+ role="slider"
+ aria-label={accessibilityLabel}
+ aria-valuemin={min}
+ aria-valuemax={max}
+ aria-valuenow={currentValue}
+ aria-valuetext={formattedLabel}
+ aria-disabled={disabled}
+ accessibilityActions={[
+ { name: 'increment', label: 'increment' },
+ { name: 'decrement', label: 'decrement' },
+ ]}
+ onAccessibilityAction={handleAccessibilityAction}
+ >
+ {/* Leading Icon */}
+ {startIcon ? (
+
+
+
+ ) : null}
+
+ {/* Main Track & Thumb Interaction Area */}
+
+ {/* Inactive Left Track */}
+ {trackWidth > 0 && inactiveLeftWidth > 0 ? (
+
+ ) : null}
+
+ {/* Active Track Highlight Segment */}
+ {trackWidth > 0 && activeWidth > 0 ? (
+
+ ) : null}
+
+ {/* Inactive Right Track */}
+ {trackWidth > 0 && inactiveRightWidth > 0 ? (
+
+ ) : null}
+
+ {/* Stop Indicators (Tick Marks) */}
+ {trackWidth > 0 && tickMarks.length > 0
+ ? tickMarks.map((tickRatio, idx) => {
+ const tickVisualRatio = isRTL ? 1 - tickRatio : tickRatio;
+ const tickPos = trackCornerRadius + tickVisualRatio * travelWidth;
+ const isActiveTick =
+ effectiveOrigin !== undefined
+ ? (tickRatio >= originRatio && tickRatio <= ratio) ||
+ (tickRatio <= originRatio && tickRatio >= ratio)
+ : tickRatio <= ratio;
+
+ const distToThumb = Math.abs(tickPos - thumbCenter);
+ if (distToThumb < halfThumb + gap) {
+ return null;
+ }
+
+ const tickColor = isActiveTick
+ ? defaultSliderColors.stopIndicatorActiveColor
+ : defaultSliderColors.stopIndicatorInactiveColor;
+
+ const tickDynamicStyle = {
+ left: tickPos - STOP_INDICATOR_SIZE / 2,
+ top: (MIN_TOUCH_TARGET_SIZE - STOP_INDICATOR_SIZE) / 2,
+ backgroundColor: disabled
+ ? defaultSliderColors.disabledStopIndicatorColor
+ : tickColor,
+ };
+
+ return (
+
+ );
+ })
+ : null}
+
+ {/* State Layer (Ripple circle around thumb) */}
+ {trackWidth > 0 ? (
+
+ ) : null}
+
+ {/* Thumb (Handle Bar) */}
+ {trackWidth > 0 ? (
+
+ ) : null}
+
+ {/* Floating Value Indicator (Tooltip) */}
+ {trackWidth > 0 && labelBehavior !== 'gone' ? (
+
+
+
+ {formattedLabel}
+
+
+
+ ) : null}
+
+
+ {/* Trailing Icon */}
+ {endIcon ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+// Web-only style; not in StyleSheet because `outline` is outside ViewStyle.
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const webNoOutline = { outline: 'none' } as unknown as ViewStyle;
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ width: '100%',
+ minHeight: MIN_TOUCH_TARGET_SIZE,
+ },
+ touchArea: {
+ flex: 1,
+ height: MIN_TOUCH_TARGET_SIZE,
+ justifyContent: 'center',
+ position: 'relative',
+ overflow: 'visible',
+ },
+ segment: {
+ position: 'absolute',
+ pointerEvents: 'none',
+ },
+ inactiveLeftSegment: {
+ left: 0,
+ },
+ thumb: {
+ position: 'absolute',
+ width: DEFAULT_THUMB_WIDTH,
+ height: DEFAULT_THUMB_HEIGHT,
+ borderRadius: THUMB_CORNER_RADIUS,
+ elevation: 1,
+ pointerEvents: 'none',
+ },
+ stateLayer: {
+ position: 'absolute',
+ width: STATE_LAYER_SIZE,
+ height: STATE_LAYER_SIZE,
+ borderRadius: cornerFull,
+ pointerEvents: 'none',
+ },
+ tickMark: {
+ position: 'absolute',
+ width: STOP_INDICATOR_SIZE,
+ height: STOP_INDICATOR_SIZE,
+ borderRadius: STOP_INDICATOR_SIZE / 2,
+ pointerEvents: 'none',
+ },
+ valueIndicatorAnchor: {
+ position: 'absolute',
+ top:
+ (MIN_TOUCH_TARGET_SIZE - DEFAULT_THUMB_HEIGHT) / 2 -
+ VALUE_INDICATOR_GAP -
+ VALUE_INDICATOR_HEIGHT,
+ height: VALUE_INDICATOR_HEIGHT,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ valueIndicator: {
+ flexDirection: 'row',
+ height: VALUE_INDICATOR_HEIGHT,
+ minWidth: VALUE_INDICATOR_MIN_WIDTH,
+ paddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL,
+ borderRadius: VALUE_INDICATOR_CORNER_RADIUS,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ valueIndicatorText: {
+ fontWeight: '500',
+ textAlign: 'center',
+ flexShrink: 0,
+ },
+ iconWrap: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ startIconWrap: {
+ marginRight: DEFAULT_ICON_GAP,
+ },
+ endIconWrap: {
+ marginLeft: DEFAULT_ICON_GAP,
+ },
+ disabledActiveTrack: {
+ opacity: DISABLED_ACTIVE_TRACK_OPACITY,
+ },
+ disabledInactiveTrack: {
+ opacity: DISABLED_INACTIVE_TRACK_OPACITY,
+ },
+ disabledHandle: {
+ opacity: DISABLED_HANDLE_OPACITY,
+ },
+ disabledStopIndicator: {
+ opacity: DISABLED_STOP_INDICATOR_OPACITY,
+ },
+ disabledIcon: {
+ opacity: DISABLED_ICON_OPACITY,
+ },
+});
+
+export default Slider;
diff --git a/src/components/Slider/index.ts b/src/components/Slider/index.ts
new file mode 100644
index 0000000000..eda5ac4721
--- /dev/null
+++ b/src/components/Slider/index.ts
@@ -0,0 +1,17 @@
+import RangeSliderComponent, {
+ type Props as RangeSliderProps,
+} from './RangeSlider';
+import SliderComponent, { type Props as SliderProps } from './Slider';
+
+const Slider = Object.assign(
+ // @component ./Slider.tsx
+ SliderComponent,
+ {
+ // @component ./RangeSlider.tsx
+ Range: RangeSliderComponent,
+ }
+);
+
+export default Slider;
+export { default as RangeSlider } from './RangeSlider';
+export type { SliderProps, RangeSliderProps };
diff --git a/src/components/Slider/tokens.ts b/src/components/Slider/tokens.ts
new file mode 100644
index 0000000000..191a55f541
--- /dev/null
+++ b/src/components/Slider/tokens.ts
@@ -0,0 +1,72 @@
+import type { ColorRole } from '../../theme/types';
+
+/**
+ * Material Design 3 Slider tokens.
+ * @see https://m3.material.io/components/sliders/specs
+ */
+const sizes = {
+ /** Height of the track in resting state */
+ trackHeight: 16,
+ /** Corner radius for outer track ends */
+ trackCornerRadius: 8,
+ /** Corner radius for inside track ends near the thumb-track gap */
+ trackInsideCornerRadius: 2,
+ /** Width of the resting thumb bar */
+ thumbWidth: 4,
+ /** Height of the resting thumb bar */
+ thumbHeight: 44,
+ /** Corner radius of the thumb */
+ thumbCornerRadius: 2,
+ /** Width of the thumb when pressed/interacted */
+ pressedThumbWidth: 6,
+ /** Size of the gap between the thumb and the track */
+ thumbTrackGapSize: 6,
+ /** Diameter of stop indicators / tick marks */
+ stopIndicatorSize: 4,
+ /** Minimum touch target size for accessibility */
+ minTouchTargetSize: 48,
+ /** Size of the interaction state layer circle */
+ stateLayerSize: 40,
+ /** Height of the floating value indicator bubble */
+ valueIndicatorHeight: 44,
+ /** Minimum width of the floating value indicator bubble */
+ valueIndicatorMinWidth: 48,
+ /** Horizontal padding for the value indicator */
+ valueIndicatorPaddingHorizontal: 12,
+ /** Corner radius of the value indicator (fully rounded pill / stadium shape) */
+ valueIndicatorCornerRadius: 22,
+ /** Gap between handle and value indicator */
+ valueIndicatorGap: 12,
+ /** Default size for leading/trailing inset icons */
+ iconSize: 24,
+ /** Gap between leading/trailing icon and track */
+ iconGap: 12,
+
+ // Opacities for disabled state
+ disabledActiveTrackOpacity: 0.38,
+ disabledInactiveTrackOpacity: 0.12,
+ disabledHandleOpacity: 0.38,
+ disabledStopIndicatorOpacity: 0.38,
+ disabledIconOpacity: 0.38,
+} as const;
+
+const colors = {
+ activeTrackColor: 'primary',
+ inactiveTrackColor: 'surfaceContainerHighest',
+ handleColor: 'primary',
+ stopIndicatorActiveColor: 'onPrimary',
+ stopIndicatorInactiveColor: 'onSurfaceVariant',
+ valueIndicatorContainerColor: 'inverseSurface',
+ valueIndicatorTextColor: 'inverseOnSurface',
+ stateLayerColor: 'primary',
+ iconColor: 'onSurfaceVariant',
+
+ // Disabled colors
+ disabledActiveTrackColor: 'onSurface',
+ disabledInactiveTrackColor: 'onSurface',
+ disabledHandleColor: 'onSurface',
+ disabledStopIndicatorColor: 'onSurface',
+ disabledIconColor: 'onSurface',
+} as const satisfies Record;
+
+export const SliderTokens = { ...sizes, ...colors };
diff --git a/src/components/Slider/utils.ts b/src/components/Slider/utils.ts
new file mode 100644
index 0000000000..28a0b15925
--- /dev/null
+++ b/src/components/Slider/utils.ts
@@ -0,0 +1,137 @@
+import type { ColorValue } from 'react-native';
+
+import { SliderTokens } from './tokens';
+import type { InternalTheme } from '../../theme/types';
+
+export type SliderColors = {
+ activeTrackColor: ColorValue;
+ inactiveTrackColor: ColorValue;
+ handleColor: ColorValue;
+ stopIndicatorActiveColor: ColorValue;
+ stopIndicatorInactiveColor: ColorValue;
+ valueIndicatorContainerColor: ColorValue;
+ valueIndicatorTextColor: ColorValue;
+ stateLayerColor: ColorValue;
+ iconColor: ColorValue;
+
+ disabledActiveTrackColor: ColorValue;
+ disabledInactiveTrackColor: ColorValue;
+ disabledHandleColor: ColorValue;
+ disabledStopIndicatorColor: ColorValue;
+ disabledIconColor: ColorValue;
+};
+
+export function getDefaultSliderColors(theme: InternalTheme): SliderColors {
+ const t = SliderTokens;
+ const c = theme.colors;
+
+ return {
+ activeTrackColor: c[t.activeTrackColor],
+ inactiveTrackColor: c[t.inactiveTrackColor],
+ handleColor: c[t.handleColor],
+ stopIndicatorActiveColor: c[t.stopIndicatorActiveColor],
+ stopIndicatorInactiveColor: c[t.stopIndicatorInactiveColor],
+ valueIndicatorContainerColor: c[t.valueIndicatorContainerColor],
+ valueIndicatorTextColor: c[t.valueIndicatorTextColor],
+ stateLayerColor: c[t.stateLayerColor],
+ iconColor: c[t.iconColor],
+
+ disabledActiveTrackColor: c[t.disabledActiveTrackColor],
+ disabledInactiveTrackColor: c[t.disabledInactiveTrackColor],
+ disabledHandleColor: c[t.disabledHandleColor],
+ disabledStopIndicatorColor: c[t.disabledStopIndicatorColor],
+ disabledIconColor: c[t.disabledIconColor],
+ };
+}
+
+/**
+ * Clamps a number between min and max inclusive.
+ */
+export function clamp(value: number, min: number, max: number): number {
+ if (value < min) return min;
+ if (value > max) return max;
+ return value;
+}
+
+/**
+ * Snaps a value to the nearest step within [min, max].
+ */
+export function snapToStep(
+ value: number,
+ min: number,
+ max: number,
+ step?: number
+): number {
+ if (typeof step !== 'number' || step <= 0) {
+ return clamp(value, min, max);
+ }
+
+ const stepsCount = Math.round((value - min) / step);
+ const snapped = min + stepsCount * step;
+
+ // Fix possible floating point inaccuracies (e.g., 0.1 + 0.2 = 0.30000000000000004)
+ const precision = (step.toString().split('.')[1] || '').length;
+ const fixed = Number(snapped.toFixed(Math.max(precision, 4)));
+
+ return clamp(fixed, min, max);
+}
+
+/**
+ * Converts a value to a ratio between 0 and 1.
+ */
+export function getRatioFromValue(
+ value: number,
+ min: number,
+ max: number
+): number {
+ if (max <= min) return 0;
+ return clamp((value - min) / (max - min), 0, 1);
+}
+
+/**
+ * Converts a 0..1 ratio to a stepped value between min and max.
+ */
+export function getValueFromRatio(
+ ratio: number,
+ min: number,
+ max: number,
+ step?: number
+): number {
+ const raw = min + ratio * (max - min);
+ return snapToStep(raw, min, max, step);
+}
+
+/**
+ * Computes the normalized tick mark ratios [0..1] for a discrete slider.
+ */
+export function getTickMarks(
+ min: number,
+ max: number,
+ step?: number
+): number[] {
+ if (typeof step !== 'number' || step <= 0 || max <= min) {
+ return [];
+ }
+
+ const ticks: number[] = [];
+ const range = max - min;
+ const stepsCount = Math.floor(range / step);
+
+ // Guard against extreme number of ticks causing performance degradation
+ if (stepsCount > 100) {
+ return [];
+ }
+
+ for (let i = 0; i <= stepsCount; i++) {
+ const val = min + i * step;
+ ticks.push((val - min) / range);
+ }
+
+ // Include the final max tick if not already added
+ const lastTick = ticks[ticks.length - 1];
+ if (lastTick !== undefined && lastTick < 0.9999) {
+ ticks.push(1);
+ }
+
+ return ticks;
+}
diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx
new file mode 100644
index 0000000000..8b6846998d
--- /dev/null
+++ b/src/components/__tests__/Slider.test.tsx
@@ -0,0 +1,364 @@
+import { Platform } from 'react-native';
+
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+} from '@jest/globals';
+
+import { fireEvent, render, screen } from '../../test-utils';
+import RangeSlider from '../Slider/RangeSlider';
+import Slider from '../Slider/Slider';
+import {
+ clamp,
+ getRatioFromValue,
+ getTickMarks,
+ getValueFromRatio,
+ snapToStep,
+} from '../Slider/utils';
+
+describe('Slider utilities', () => {
+ it('clamps values within min and max', () => {
+ expect(clamp(150, 0, 100)).toBe(100);
+ expect(clamp(-50, 0, 100)).toBe(0);
+ expect(clamp(45, 0, 100)).toBe(45);
+ });
+
+ it('snaps values to nearest step', () => {
+ expect(snapToStep(23, 0, 100, 10)).toBe(20);
+ expect(snapToStep(27, 0, 100, 10)).toBe(30);
+ expect(snapToStep(0.24, 0, 1, 0.1)).toBe(0.2);
+ expect(snapToStep(0.26, 0, 1, 0.1)).toBe(0.3);
+ });
+
+ it('computes ratio and value accurately', () => {
+ expect(getRatioFromValue(50, 0, 100)).toBe(0.5);
+ expect(getRatioFromValue(0, -50, 50)).toBe(0.5);
+ expect(getValueFromRatio(0.5, 0, 100, 5)).toBe(50);
+ });
+
+ it('generates normalized tick marks', () => {
+ const ticks = getTickMarks(0, 100, 25);
+ expect(ticks).toEqual([0, 0.25, 0.5, 0.75, 1]);
+ });
+});
+
+describe('Slider render', () => {
+ it('renders standard continuous slider', async () => {
+ const { toJSON } = await render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders discrete slider with tick marks', async () => {
+ const { toJSON } = await render(
+
+ );
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders centered slider', async () => {
+ const { toJSON } = await render(
+
+ );
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders disabled slider', async () => {
+ const { toJSON } = await render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders with start and end icons', async () => {
+ const { toJSON } = await render(
+
+ );
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders with value indicator visible', async () => {
+ const { toJSON } = await render(
+ `${v}%`}
+ />
+ );
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
+
+describe('Slider accessibility', () => {
+ it('has adjustable role and exposes accessibility value attributes', async () => {
+ await render(
+ `${v}%`}
+ />
+ );
+
+ const slider = screen.getByRole('slider');
+ expect(slider).toBeOnTheScreen();
+ expect(slider).toHaveProp('aria-valuenow', 42);
+ expect(slider).toHaveProp('aria-valuemin', 0);
+ expect(slider).toHaveProp('aria-valuemax', 100);
+ expect(slider).toHaveProp('aria-valuetext', '42%');
+ expect(slider).toHaveProp('aria-label', 'Volume');
+ expect(slider).toBeEnabled();
+ });
+
+ it('sets aria-disabled when disabled', async () => {
+ await render();
+
+ const slider = screen.getByRole('slider');
+ expect(slider).toBeDisabled();
+ expect(slider).toHaveProp('aria-disabled', true);
+ });
+});
+
+describe('RangeSlider render & accessibility', () => {
+ it('renders range slider', async () => {
+ const { toJSON } = await render(
+
+ );
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('renders disabled range slider', async () => {
+ const { toJSON } = await render();
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('has two accessible slider handles for range', async () => {
+ await render(
+
+ );
+
+ const sliders = screen.getAllByRole('slider');
+ expect(sliders).toHaveLength(2);
+
+ expect(sliders[0]).toHaveProp('aria-label', 'Price minimum');
+ expect(sliders[0]).toHaveProp('aria-valuenow', 25);
+
+ expect(sliders[1]).toHaveProp('aria-label', 'Price maximum');
+ expect(sliders[1]).toHaveProp('aria-valuenow', 75);
+ });
+});
+
+describe('Slider interaction (web keyboard navigation)', () => {
+ const originalOS = Platform.OS;
+
+ beforeEach(() => {
+ Platform.OS = 'web';
+ });
+
+ afterEach(() => {
+ Platform.OS = originalOS;
+ });
+
+ it('navigates with ArrowRight and ArrowLeft', async () => {
+ const onValueChange = jest.fn();
+ const onSlidingComplete = jest.fn();
+
+ await render(
+
+ );
+
+ const slider = screen.getByRole('slider');
+ await fireEvent(slider, 'keyDown', {
+ key: 'ArrowRight',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).toHaveBeenCalledWith(55);
+ expect(onSlidingComplete).toHaveBeenCalledWith(55);
+
+ await fireEvent(slider, 'keyDown', {
+ key: 'ArrowLeft',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).toHaveBeenCalledWith(50);
+ });
+
+ it('navigates with Home and End', async () => {
+ const onValueChange = jest.fn();
+
+ await render(
+
+ );
+
+ const slider = screen.getByRole('slider');
+ await fireEvent(slider, 'keyDown', {
+ key: 'Home',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).toHaveBeenCalledWith(0);
+
+ await fireEvent(slider, 'keyDown', {
+ key: 'End',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).toHaveBeenCalledWith(100);
+ });
+
+ it('does not respond to keyboard navigation when disabled', async () => {
+ const onValueChange = jest.fn();
+
+ await render(
+
+ );
+
+ const slider = screen.getByRole('slider');
+ await fireEvent(slider, 'keyDown', {
+ key: 'ArrowRight',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+});
+
+describe('Slider accessibility actions (screen reader VoiceOver/TalkBack)', () => {
+ it('increments and decrements value on accessibilityAction', async () => {
+ const onValueChange = jest.fn();
+ const onSlidingComplete = jest.fn();
+
+ await render(
+
+ );
+
+ const slider = screen.getByRole('slider');
+
+ await fireEvent(slider, 'accessibilityAction', {
+ nativeEvent: { actionName: 'increment' },
+ });
+ expect(onValueChange).toHaveBeenCalledWith(60);
+ expect(onSlidingComplete).toHaveBeenCalledWith(60);
+
+ await fireEvent(slider, 'accessibilityAction', {
+ nativeEvent: { actionName: 'decrement' },
+ });
+ expect(onValueChange).toHaveBeenCalledWith(50);
+ });
+});
+
+describe('RangeSlider interaction (keyboard navigation & accessibility actions)', () => {
+ const originalOS = Platform.OS;
+
+ beforeEach(() => {
+ Platform.OS = 'web';
+ });
+
+ afterEach(() => {
+ Platform.OS = originalOS;
+ });
+
+ it('navigates start and end thumbs with ArrowRight / ArrowLeft respecting minSeparation', async () => {
+ const onValueChange = jest.fn();
+ const onSlidingComplete = jest.fn();
+
+ await render(
+
+ );
+
+ const startThumb = screen.getByLabelText('Min');
+ const endThumb = screen.getByLabelText('Max');
+
+ // Move start thumb right
+ await fireEvent(startThumb, 'keyDown', {
+ key: 'ArrowRight',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).toHaveBeenCalledWith([35, 70]);
+ expect(onSlidingComplete).toHaveBeenCalledWith([35, 70]);
+
+ // Move end thumb left
+ await fireEvent(endThumb, 'keyDown', {
+ key: 'ArrowLeft',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).toHaveBeenCalledWith([35, 65]);
+
+ // Move start thumb to End (should clamp to endThumb - minSeparation = 65 - 10 = 55)
+ await fireEvent(startThumb, 'keyDown', {
+ key: 'End',
+ preventDefault: jest.fn(),
+ });
+ expect(onValueChange).toHaveBeenCalledWith([55, 65]);
+ });
+
+ it('adjusts start and end thumbs via accessibilityAction', async () => {
+ const onValueChange = jest.fn();
+
+ await render(
+
+ );
+
+ const startThumb = screen.getByLabelText('Min');
+ const endThumb = screen.getByLabelText('Max');
+
+ await fireEvent(startThumb, 'accessibilityAction', {
+ nativeEvent: { actionName: 'increment' },
+ });
+ expect(onValueChange).toHaveBeenCalledWith([30, 80]);
+
+ await fireEvent(endThumb, 'accessibilityAction', {
+ nativeEvent: { actionName: 'decrement' },
+ });
+ expect(onValueChange).toHaveBeenCalledWith([30, 70]);
+ });
+});
diff --git a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap
new file mode 100644
index 0000000000..2372a9c6f2
--- /dev/null
+++ b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap
@@ -0,0 +1,1183 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RangeSlider render & accessibility renders disabled range slider 1`] = `
+
+
+
+
+
+
+
+ 20
+
+
+
+
+
+
+
+
+ 80
+
+
+
+
+
+`;
+
+exports[`RangeSlider render & accessibility renders range slider 1`] = `
+
+
+
+
+
+
+
+ 20
+
+
+
+
+
+
+
+
+ 80
+
+
+
+
+
+`;
+
+exports[`Slider render renders centered slider 1`] = `
+
+
+
+`;
+
+exports[`Slider render renders disabled slider 1`] = `
+
+
+
+`;
+
+exports[`Slider render renders discrete slider with tick marks 1`] = `
+
+
+
+`;
+
+exports[`Slider render renders standard continuous slider 1`] = `
+
+
+
+`;
+
+exports[`Slider render renders with start and end icons 1`] = `
+
+
+
+ volume-low
+
+
+
+
+
+ volume-high
+
+
+
+`;
+
+exports[`Slider render renders with value indicator visible 1`] = `
+
+
+
+`;
diff --git a/src/index.tsx b/src/index.tsx
index 1807d2f249..5998ed3318 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -44,6 +44,7 @@ export { default as Searchbar } from './components/Searchbar';
export { default as Snackbar } from './components/Snackbar';
export { default as Surface } from './components/Surface';
export { default as Switch } from './components/Switch/Switch';
+export { default as Slider, RangeSlider } from './components/Slider';
export { default as Appbar } from './components/Appbar';
export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple';
export { default as TextInput } from './components/TextInput';
@@ -127,6 +128,7 @@ export type { Props as SearchbarProps } from './components/Searchbar';
export type { Props as SnackbarProps } from './components/Snackbar';
export type { Props as SurfaceProps } from './components/Surface';
export type { Props as SwitchProps } from './components/Switch/Switch';
+export type { SliderProps, RangeSliderProps } from './components/Slider';
export type {
TextInputProps,
TextInputRenderProps,