diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 9b2ed88..7c70c95 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -4,6 +4,7 @@ import * as SplashScreen from 'expo-splash-screen';
import * as SystemUI from 'expo-system-ui';
import { useEffect } from 'react';
import { StatusBar } from 'react-native';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { queryClient } from '../hooks/queryClient';
@@ -36,24 +37,25 @@ export default function RootLayout() {
if (!ready) return null;
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/switch-demo.tsx b/src/app/switch-demo.tsx
index 0df5628..23c8d2e 100644
--- a/src/app/switch-demo.tsx
+++ b/src/app/switch-demo.tsx
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
+import { Button } from '@/components/Button';
import { FormField, Switch } from '@/components/form';
-import { Screen } from '@/components/ui/Screen';
+import { RangeSlider, Screen, Slider } from '@/components/ui';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
@@ -10,12 +11,24 @@ import { typography } from '@/theme/typography';
export default function SwitchDemoScreen() {
const [pushEnabled, setPushEnabled] = useState(true);
const [weeklyDigestEnabled, setWeeklyDigestEnabled] = useState(false);
+ const [locationEnabled, setLocationEnabled] = useState(true);
+ const [autoPlayEnabled, setAutoPlayEnabled] = useState(false);
+ const [radiusKm, setRadiusKm] = useState(25);
+ const [resultCount, setResultCount] = useState(128);
+ const [priceRange, setPriceRange] = useState<[number, number]>([0, 65]);
+
+ const formatKm = (v: number) => `${v} km`;
+ const formatChf = (v: number) => `${v} CHF`;
+ const formatPriceRange = (minValue: number, maxValue: number) => {
+ const left = minValue === 0 ? 'Gratuit' : `${minValue} CHF`;
+ return `${left} - ${maxValue} CHF`;
+ };
return (
- DESIGN SYSTEM · SWITCH
- Demos du composant Switch
+ DESIGN SYSTEM · SWITCH & SLIDER
+ Demos phase 4
+
+
+
+
+ Localisation precise
+
+
+
+
+ Lecture auto des extraits
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const nextCount = Math.max(12, 220 - v * 4);
+ setResultCount(nextCount);
+ }}
+ min={1}
+ max={50}
+ step={1}
+ formatValue={formatKm}
+ showBounds
+ ticks={[1, 10, 25, 50]}
+ />
+
+
+
+
+
+
+
+ undefined}
+ min={0}
+ max={100}
+ step={1}
+ formatValue={(v) => `${v}%`}
+ disabled
+ showBounds
+ />
+
);
}
@@ -115,4 +220,27 @@ const styles = StyleSheet.create({
...typography.caption,
color: colors.textSecondary,
},
+ switchStack: {
+ gap: spacing[8],
+ },
+ switchRowCompact: {
+ minHeight: 56,
+ borderWidth: 1,
+ borderColor: colors.inputBorder,
+ borderRadius: 14,
+ backgroundColor: colors.inputBackground,
+ paddingHorizontal: 14,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing[12],
+ },
+ inlineActions: {
+ marginTop: spacing[4],
+ },
+ sectionDivider: {
+ height: 1,
+ backgroundColor: colors.divider,
+ marginVertical: spacing[4],
+ },
});
diff --git a/src/components/ui/RangeSlider.tsx b/src/components/ui/RangeSlider.tsx
new file mode 100644
index 0000000..fc07f46
--- /dev/null
+++ b/src/components/ui/RangeSlider.tsx
@@ -0,0 +1,619 @@
+import * as Haptics from 'expo-haptics';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import {
+ LayoutChangeEvent,
+ StyleSheet,
+ Text,
+ View,
+ type AccessibilityActionEvent,
+} from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+
+import { colors } from '@/theme/colors';
+import { sliderMetrics, sliderMotion, spacing } from '@/theme/spacing';
+import { typography } from '@/theme/typography';
+
+import { SliderFill, SliderThumb, SliderTrack, sliderSizes } from './sliderParts';
+
+export type RangeSliderProps = {
+ value: [number, number];
+ onChange: (v: [number, number]) => void;
+ onChangeEnd?: (v: [number, number]) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+ minGap?: number;
+ label?: string;
+ formatValue?: (v: number) => string;
+ formatRange?: (a: number, b: number) => string;
+ showBounds?: boolean;
+ ticks?: number[];
+ disabled?: boolean;
+ haptics?: boolean;
+};
+
+const accessibilityActions = [{ name: 'increment' }, { name: 'decrement' }] as const;
+
+type ThumbKey = 'min' | 'max';
+
+function clamp(value: number, min: number, max: number): number {
+ 'worklet';
+ return Math.min(max, Math.max(min, value));
+}
+
+function safeStep(step: number): number {
+ return Number.isFinite(step) && step > 0 ? step : 1;
+}
+
+function quantize(value: number, min: number, max: number, step: number): number {
+ 'worklet';
+ const q = Math.round((value - min) / step) * step + min;
+ return clamp(q, min, max);
+}
+
+function valueToX(value: number, min: number, max: number, trackWidth: number): number {
+ 'worklet';
+ if (trackWidth <= 0 || max <= min) {
+ return 0;
+ }
+
+ const ratio = (value - min) / (max - min);
+ return clamp(ratio, 0, 1) * trackWidth;
+}
+
+function xToValue(x: number, min: number, max: number, step: number, trackWidth: number): number {
+ 'worklet';
+ if (trackWidth <= 0 || max <= min) {
+ return min;
+ }
+
+ const clampedX = clamp(x, 0, trackWidth);
+ const raw = min + (clampedX / trackWidth) * (max - min);
+ return quantize(raw, min, max, step);
+}
+
+function normalizeRange(
+ range: [number, number],
+ min: number,
+ max: number,
+ step: number,
+ minGap: number,
+): [number, number] {
+ const start = quantize(range[0], min, max, step);
+ const end = quantize(range[1], min, max, step);
+ const boundedMinGap = Math.max(minGap, step);
+ const normalizedStart = clamp(start, min, end - boundedMinGap);
+ const normalizedEnd = clamp(end, normalizedStart + boundedMinGap, max);
+
+ return [normalizedStart, normalizedEnd];
+}
+
+export function RangeSlider({
+ value,
+ onChange,
+ onChangeEnd,
+ min = 0,
+ max = 100,
+ step = 1,
+ minGap = 1,
+ label,
+ formatValue,
+ formatRange,
+ showBounds = false,
+ ticks,
+ disabled = false,
+ haptics = true,
+}: RangeSliderProps) {
+ const normalizedStep = safeStep(step);
+ const boundedMinGap = Math.max(minGap, normalizedStep);
+ const formatSingle = formatValue ?? ((v: number) => String(v));
+ const formatPair = formatRange ?? ((a: number, b: number) => `${a} – ${b}`);
+
+ const normalizedRange = useMemo(
+ () => normalizeRange(value, min, max, normalizedStep, boundedMinGap),
+ [boundedMinGap, max, min, normalizedStep, value],
+ );
+
+ const trackWidth = useSharedValue(0);
+ const minX = useSharedValue(0);
+ const maxX = useSharedValue(0);
+ const minValue = useSharedValue(normalizedRange[0]);
+ const maxValue = useSharedValue(normalizedRange[1]);
+ const startX = useSharedValue(0);
+ const activeThumb = useSharedValue<0 | 1 | 2>(0);
+ const minScale = useSharedValue(1);
+ const maxScale = useSharedValue(1);
+
+ const lastReportedRef = useRef<[number, number]>(normalizedRange);
+
+ const emitHaptic = useCallback(() => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
+ }, []);
+
+ const emitChange = useCallback(
+ (nextMin: number, nextMax: number) => {
+ const [prevMin, prevMax] = lastReportedRef.current;
+ if (prevMin === nextMin && prevMax === nextMax) {
+ return;
+ }
+
+ lastReportedRef.current = [nextMin, nextMax];
+ onChange([nextMin, nextMax]);
+ if (haptics) {
+ emitHaptic();
+ }
+ },
+ [emitHaptic, haptics, onChange],
+ );
+
+ const emitChangeEnd = useCallback(
+ (nextMin: number, nextMax: number) => {
+ onChangeEnd?.([nextMin, nextMax]);
+ },
+ [onChangeEnd],
+ );
+
+ const syncFromProp = useCallback(() => {
+ if (trackWidth.value <= 0 || activeThumb.value !== 0) {
+ minValue.value = normalizedRange[0];
+ maxValue.value = normalizedRange[1];
+ return;
+ }
+
+ const nextMinX = valueToX(normalizedRange[0], min, max, trackWidth.value);
+ const nextMaxX = valueToX(normalizedRange[1], min, max, trackWidth.value);
+
+ minValue.value = normalizedRange[0];
+ maxValue.value = normalizedRange[1];
+ minX.value = withSpring(nextMinX, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ maxX.value = withSpring(nextMaxX, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ }, [activeThumb, max, maxValue, maxX, min, minValue, minX, normalizedRange, trackWidth]);
+
+ useEffect(() => {
+ lastReportedRef.current = normalizedRange;
+ syncFromProp();
+ }, [normalizedRange, syncFromProp]);
+
+ const onTrackLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const nextWidth = event.nativeEvent.layout.width;
+ trackWidth.value = nextWidth;
+
+ minValue.value = normalizedRange[0];
+ maxValue.value = normalizedRange[1];
+ minX.value = valueToX(normalizedRange[0], min, max, nextWidth);
+ maxX.value = valueToX(normalizedRange[1], min, max, nextWidth);
+ },
+ [max, maxValue, maxX, min, minValue, minX, normalizedRange, trackWidth],
+ );
+
+ const minPan = Gesture.Pan()
+ .enabled(!disabled)
+ .hitSlop({
+ left: sliderSizes.thumbHitSlop,
+ right: sliderSizes.thumbHitSlop,
+ top: sliderSizes.thumbHitSlop,
+ bottom: sliderSizes.thumbHitSlop,
+ })
+ .onStart((event) => {
+ if (trackWidth.value <= 0) {
+ return;
+ }
+
+ const distanceBetweenThumbs = Math.abs(maxX.value - minX.value);
+ const touchX = event.x;
+ const minCenter = minX.value;
+ const maxCenter = maxX.value;
+ const distToMin = Math.abs(touchX - minCenter);
+ const distToMax = Math.abs(touchX - maxCenter);
+ const draggableRadius = sliderSizes.thumbSize / 2 + sliderSizes.thumbHitSlop;
+
+ if (distToMin > draggableRadius) {
+ return;
+ }
+
+ if (distanceBetweenThumbs < sliderMetrics.closeThumbThresholdPx && distToMin > distToMax) {
+ return;
+ }
+
+ activeThumb.value = 1;
+ startX.value = minX.value;
+ minScale.value = withSpring(sliderMotion.activeScale, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ })
+ .onUpdate((event) => {
+ if (activeThumb.value !== 1 || trackWidth.value <= 0) {
+ return;
+ }
+
+ const limitMaxValue = maxValue.value - boundedMinGap;
+ const rawX = clamp(startX.value + event.translationX, 0, trackWidth.value);
+ const rawValue = xToValue(rawX, min, max, normalizedStep, trackWidth.value);
+ const nextMinValue = clamp(rawValue, min, limitMaxValue);
+ const snappedX = valueToX(nextMinValue, min, max, trackWidth.value);
+
+ if (nextMinValue !== minValue.value) {
+ minValue.value = nextMinValue;
+ minX.value = snappedX;
+ runOnJS(emitChange)(nextMinValue, maxValue.value);
+ }
+ })
+ .onEnd(() => {
+ if (activeThumb.value !== 1) {
+ return;
+ }
+
+ activeThumb.value = 0;
+ minScale.value = withSpring(1, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ runOnJS(emitChangeEnd)(minValue.value, maxValue.value);
+ })
+ .onFinalize(() => {
+ if (activeThumb.value === 1) {
+ activeThumb.value = 0;
+ }
+ minScale.value = withSpring(1, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ });
+
+ const maxPan = Gesture.Pan()
+ .enabled(!disabled)
+ .hitSlop({
+ left: sliderSizes.thumbHitSlop,
+ right: sliderSizes.thumbHitSlop,
+ top: sliderSizes.thumbHitSlop,
+ bottom: sliderSizes.thumbHitSlop,
+ })
+ .onStart((event) => {
+ if (trackWidth.value <= 0) {
+ return;
+ }
+
+ const distanceBetweenThumbs = Math.abs(maxX.value - minX.value);
+ const touchX = event.x;
+ const minCenter = minX.value;
+ const maxCenter = maxX.value;
+ const distToMin = Math.abs(touchX - minCenter);
+ const distToMax = Math.abs(touchX - maxCenter);
+ const draggableRadius = sliderSizes.thumbSize / 2 + sliderSizes.thumbHitSlop;
+
+ if (distToMax > draggableRadius) {
+ return;
+ }
+
+ if (distanceBetweenThumbs < sliderMetrics.closeThumbThresholdPx && distToMax > distToMin) {
+ return;
+ }
+
+ activeThumb.value = 2;
+ startX.value = maxX.value;
+ maxScale.value = withSpring(sliderMotion.activeScale, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ })
+ .onUpdate((event) => {
+ if (activeThumb.value !== 2 || trackWidth.value <= 0) {
+ return;
+ }
+
+ const limitMinValue = minValue.value + boundedMinGap;
+ const rawX = clamp(startX.value + event.translationX, 0, trackWidth.value);
+ const rawValue = xToValue(rawX, min, max, normalizedStep, trackWidth.value);
+ const nextMaxValue = clamp(rawValue, limitMinValue, max);
+ const snappedX = valueToX(nextMaxValue, min, max, trackWidth.value);
+
+ if (nextMaxValue !== maxValue.value) {
+ maxValue.value = nextMaxValue;
+ maxX.value = snappedX;
+ runOnJS(emitChange)(minValue.value, nextMaxValue);
+ }
+ })
+ .onEnd(() => {
+ if (activeThumb.value !== 2) {
+ return;
+ }
+
+ activeThumb.value = 0;
+ maxScale.value = withSpring(1, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ runOnJS(emitChangeEnd)(minValue.value, maxValue.value);
+ })
+ .onFinalize(() => {
+ if (activeThumb.value === 2) {
+ activeThumb.value = 0;
+ }
+ maxScale.value = withSpring(1, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ });
+
+ const tapGesture = Gesture.Tap()
+ .enabled(!disabled)
+ .onStart((event) => {
+ if (trackWidth.value <= 0) {
+ return;
+ }
+
+ const tapX = clamp(event.x, 0, trackWidth.value);
+ const distToMin = Math.abs(tapX - minX.value);
+ const distToMax = Math.abs(tapX - maxX.value);
+ const target: ThumbKey = distToMin <= distToMax ? 'min' : 'max';
+
+ const tappedValue = xToValue(tapX, min, max, normalizedStep, trackWidth.value);
+
+ if (target === 'min') {
+ const nextMin = clamp(tappedValue, min, maxValue.value - boundedMinGap);
+ minValue.value = nextMin;
+ minX.value = withSpring(valueToX(nextMin, min, max, trackWidth.value), {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ runOnJS(emitChange)(nextMin, maxValue.value);
+ runOnJS(emitChangeEnd)(nextMin, maxValue.value);
+ return;
+ }
+
+ const nextMax = clamp(tappedValue, minValue.value + boundedMinGap, max);
+ maxValue.value = nextMax;
+ maxX.value = withSpring(valueToX(nextMax, min, max, trackWidth.value), {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ runOnJS(emitChange)(minValue.value, nextMax);
+ runOnJS(emitChangeEnd)(minValue.value, nextMax);
+ });
+
+ const gesture = Gesture.Simultaneous(minPan, maxPan, tapGesture);
+
+ const fillStyle = useAnimatedStyle(() => ({
+ left: minX.value,
+ width: Math.max(maxX.value - minX.value, 0),
+ }));
+
+ const minThumbStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: minX.value }, { scale: minScale.value }],
+ }));
+
+ const maxThumbStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: maxX.value }, { scale: maxScale.value }],
+ }));
+
+ const visualTicks = useMemo(() => {
+ if (!ticks || ticks.length === 0 || max <= min) {
+ return [];
+ }
+
+ return ticks
+ .map((tickValue) => {
+ const ratio = (tickValue - min) / (max - min);
+ const clampedRatio = Math.min(1, Math.max(0, ratio));
+ return { key: String(tickValue), left: `${clampedRatio * 100}%` };
+ });
+ }, [max, min, ticks]);
+
+ const applyAccessibilityStep = useCallback(
+ (thumb: ThumbKey, direction: 1 | -1) => {
+ const [currentMin, currentMax] = normalizedRange;
+
+ if (thumb === 'min') {
+ const nextMin = clamp(
+ quantize(currentMin + direction * normalizedStep, min, max, normalizedStep),
+ min,
+ currentMax - boundedMinGap,
+ );
+
+ if (nextMin === currentMin) {
+ return;
+ }
+
+ onChange([nextMin, currentMax]);
+ onChangeEnd?.([nextMin, currentMax]);
+ } else {
+ const nextMax = clamp(
+ quantize(currentMax + direction * normalizedStep, min, max, normalizedStep),
+ currentMin + boundedMinGap,
+ max,
+ );
+
+ if (nextMax === currentMax) {
+ return;
+ }
+
+ onChange([currentMin, nextMax]);
+ onChangeEnd?.([currentMin, nextMax]);
+ }
+
+ if (haptics) {
+ emitHaptic();
+ }
+ },
+ [boundedMinGap, emitHaptic, haptics, max, min, normalizedRange, normalizedStep, onChange, onChangeEnd],
+ );
+
+ const onAccessibilityActionMin = useCallback(
+ (event: AccessibilityActionEvent) => {
+ const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1;
+ applyAccessibilityStep('min', direction);
+ },
+ [applyAccessibilityStep],
+ );
+
+ const onAccessibilityActionMax = useCallback(
+ (event: AccessibilityActionEvent) => {
+ const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1;
+ applyAccessibilityStep('max', direction);
+ },
+ [applyAccessibilityStep],
+ );
+
+ const trackTop = (sliderSizes.minTouchSize - sliderSizes.trackHeight) / 2;
+ const thumbTop = (sliderSizes.minTouchSize - sliderSizes.thumbSize) / 2;
+
+ return (
+
+ {(label || formatPair) ? (
+
+ {label ?? ''}
+
+ {formatPair(normalizedRange[0], normalizedRange[1])}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ {visualTicks.length > 0 ? (
+
+ {visualTicks.map((tick) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ {showBounds ? (
+
+ {formatSingle(min)}
+ {formatSingle(max)}
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ gap: spacing[8],
+ },
+ disabled: {
+ opacity: 0.45,
+ },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing[8],
+ },
+ headerLabel: {
+ ...typography.overlineSm,
+ color: colors.textSecondary,
+ flexShrink: 1,
+ },
+ headerValue: {
+ ...typography.cardTitle,
+ color: colors.textPrimary,
+ },
+ railContainer: {
+ minHeight: sliderSizes.minTouchSize,
+ justifyContent: 'center',
+ },
+ track: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ },
+ fillBase: {
+ position: 'absolute',
+ },
+ thumbPosition: {
+ position: 'absolute',
+ },
+ ticksRow: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: sliderSizes.minTouchSize - sliderMetrics.trackHeight,
+ height: spacing[12],
+ },
+ tickMark: {
+ position: 'absolute',
+ width: spacing[2],
+ height: spacing[6],
+ borderRadius: spacing[2],
+ marginLeft: -spacing[2] / 2,
+ backgroundColor: colors.inputBorder,
+ },
+ boundsRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ boundsText: {
+ ...typography.metadataSm,
+ fontFamily: typography.overlineSm.fontFamily,
+ color: colors.textSecondary,
+ },
+});
+
+export default RangeSlider;
diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx
new file mode 100644
index 0000000..adafcf9
--- /dev/null
+++ b/src/components/ui/Slider.tsx
@@ -0,0 +1,415 @@
+import * as Haptics from 'expo-haptics';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import {
+ LayoutChangeEvent,
+ StyleSheet,
+ Text,
+ View,
+ type AccessibilityActionEvent
+} from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+
+import { colors } from '@/theme/colors';
+import { sliderMetrics, sliderMotion, spacing } from '@/theme/spacing';
+import { typography } from '@/theme/typography';
+
+import { SliderFill, SliderThumb, SliderTrack, sliderSizes } from './sliderParts';
+
+export type SliderProps = {
+ value: number;
+ onChange: (v: number) => void;
+ onChangeEnd?: (v: number) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+ label?: string;
+ formatValue?: (v: number) => string;
+ showBounds?: boolean;
+ ticks?: number[];
+ disabled?: boolean;
+ haptics?: boolean;
+};
+
+const accessibilityActions = [{ name: 'increment' }, { name: 'decrement' }] as const;
+
+function clamp(value: number, min: number, max: number): number {
+ 'worklet';
+ return Math.min(max, Math.max(min, value));
+}
+
+function safeStep(step: number): number {
+ return Number.isFinite(step) && step > 0 ? step : 1;
+}
+
+function quantize(value: number, min: number, max: number, step: number): number {
+ 'worklet';
+ const q = Math.round((value - min) / step) * step + min;
+ return clamp(q, min, max);
+}
+
+function valueToX(value: number, min: number, max: number, trackWidth: number): number {
+ 'worklet';
+ if (trackWidth <= 0 || max <= min) {
+ return 0;
+ }
+
+ const ratio = (value - min) / (max - min);
+ return clamp(ratio, 0, 1) * trackWidth;
+}
+
+function xToValue(x: number, min: number, max: number, step: number, trackWidth: number): number {
+ 'worklet';
+ if (trackWidth <= 0 || max <= min) {
+ return min;
+ }
+
+ const clampedX = clamp(x, 0, trackWidth);
+ const raw = min + (clampedX / trackWidth) * (max - min);
+ return quantize(raw, min, max, step);
+}
+
+export function Slider({
+ value,
+ onChange,
+ onChangeEnd,
+ min = 0,
+ max = 100,
+ step = 1,
+ label,
+ formatValue,
+ showBounds = false,
+ ticks,
+ disabled = false,
+ haptics = true,
+}: SliderProps) {
+ const normalizedStep = safeStep(step);
+ const format = formatValue ?? ((v: number) => String(v));
+ const normalizedValue = useMemo(
+ () => quantize(value, min, max, normalizedStep),
+ [max, min, normalizedStep, value],
+ );
+
+ const trackWidth = useSharedValue(0);
+ const thumbX = useSharedValue(0);
+ const currentValue = useSharedValue(normalizedValue);
+ const startX = useSharedValue(0);
+ const thumbScale = useSharedValue(1);
+ const isDragging = useSharedValue(false);
+
+ const lastReportedRef = useRef(normalizedValue);
+
+ const emitHaptic = useCallback(() => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
+ }, []);
+
+ const emitChange = useCallback(
+ (next: number) => {
+ if (lastReportedRef.current === next) {
+ return;
+ }
+
+ lastReportedRef.current = next;
+ onChange(next);
+ if (haptics) {
+ emitHaptic();
+ }
+ },
+ [emitHaptic, haptics, onChange],
+ );
+
+ const emitChangeEnd = useCallback(
+ (next: number) => {
+ onChangeEnd?.(next);
+ },
+ [onChangeEnd],
+ );
+
+ const syncFromProp = useCallback(() => {
+ if (trackWidth.value <= 0 || isDragging.value) {
+ currentValue.value = normalizedValue;
+ return;
+ }
+
+ const x = valueToX(normalizedValue, min, max, trackWidth.value);
+ currentValue.value = normalizedValue;
+ thumbX.value = withSpring(x, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ }, [currentValue, isDragging, max, min, normalizedValue, thumbX, trackWidth]);
+
+ useEffect(() => {
+ lastReportedRef.current = normalizedValue;
+ syncFromProp();
+ }, [normalizedValue, syncFromProp]);
+
+ const onTrackLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const nextWidth = event.nativeEvent.layout.width;
+ trackWidth.value = nextWidth;
+ currentValue.value = normalizedValue;
+ thumbX.value = valueToX(normalizedValue, min, max, nextWidth);
+ },
+ [currentValue, max, min, normalizedValue, thumbX, trackWidth],
+ );
+
+ const panGesture = Gesture.Pan()
+ .enabled(!disabled)
+ .hitSlop({
+ left: sliderSizes.thumbHitSlop,
+ right: sliderSizes.thumbHitSlop,
+ top: sliderSizes.thumbHitSlop,
+ bottom: sliderSizes.thumbHitSlop,
+ })
+ .onStart((event) => {
+ if (trackWidth.value <= 0) {
+ return;
+ }
+
+ const centerX = thumbX.value;
+ const distance = Math.abs(event.x - centerX);
+ const draggableRadius = sliderSizes.thumbSize / 2 + sliderSizes.thumbHitSlop;
+
+ if (distance > draggableRadius) {
+ isDragging.value = false;
+ return;
+ }
+
+ isDragging.value = true;
+ startX.value = thumbX.value;
+ thumbScale.value = withSpring(sliderMotion.activeScale, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ })
+ .onUpdate((event) => {
+ if (!isDragging.value || trackWidth.value <= 0) {
+ return;
+ }
+
+ const rawX = clamp(startX.value + event.translationX, 0, trackWidth.value);
+ const nextValue = xToValue(rawX, min, max, normalizedStep, trackWidth.value);
+ const snappedX = valueToX(nextValue, min, max, trackWidth.value);
+
+ if (nextValue !== currentValue.value) {
+ currentValue.value = nextValue;
+ thumbX.value = snappedX;
+ runOnJS(emitChange)(nextValue);
+ }
+ })
+ .onEnd(() => {
+ if (!isDragging.value) {
+ return;
+ }
+
+ isDragging.value = false;
+ thumbScale.value = withSpring(1, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ runOnJS(emitChangeEnd)(currentValue.value);
+ })
+ .onFinalize(() => {
+ isDragging.value = false;
+ thumbScale.value = withSpring(1, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+ });
+
+ const tapGesture = Gesture.Tap()
+ .enabled(!disabled)
+ .onStart((event) => {
+ if (trackWidth.value <= 0) {
+ return;
+ }
+
+ const nextValue = xToValue(event.x, min, max, normalizedStep, trackWidth.value);
+ const snappedX = valueToX(nextValue, min, max, trackWidth.value);
+
+ currentValue.value = nextValue;
+ thumbX.value = withSpring(snappedX, {
+ damping: sliderMotion.springDamping,
+ stiffness: sliderMotion.springStiffness,
+ mass: sliderMotion.springMass,
+ });
+
+ runOnJS(emitChange)(nextValue);
+ runOnJS(emitChangeEnd)(nextValue);
+ });
+
+ const gesture = Gesture.Race(panGesture, tapGesture);
+
+ const fillStyle = useAnimatedStyle(() => ({
+ width: thumbX.value,
+ }));
+
+ const thumbStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: thumbX.value }, { scale: thumbScale.value }],
+ }));
+
+ const visualTicks = useMemo(() => {
+ if (!ticks || ticks.length === 0 || max <= min) {
+ return [];
+ }
+
+ return ticks
+ .map((tickValue) => {
+ const ratio = (tickValue - min) / (max - min);
+ const clampedRatio = Math.min(1, Math.max(0, ratio));
+ return { key: String(tickValue), left: `${clampedRatio * 100}%` };
+ });
+ }, [max, min, ticks]);
+
+ const handleAccessibilityAction = useCallback(
+ (event: AccessibilityActionEvent) => {
+ const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1;
+ const next = quantize(
+ normalizedValue + direction * normalizedStep,
+ min,
+ max,
+ normalizedStep,
+ );
+
+ if (next === normalizedValue) {
+ return;
+ }
+
+ onChange(next);
+ onChangeEnd?.(next);
+ if (haptics) {
+ emitHaptic();
+ }
+ },
+ [emitHaptic, haptics, max, min, normalizedStep, normalizedValue, onChange, onChangeEnd],
+ );
+
+ const trackTop = (sliderSizes.minTouchSize - sliderSizes.trackHeight) / 2;
+ const thumbTop = (sliderSizes.minTouchSize - sliderSizes.thumbSize) / 2;
+
+ return (
+
+ {(label || format) ? (
+
+ {label ?? ''}
+ {format(normalizedValue)}
+
+ ) : null}
+
+
+
+
+
+
+
+ {visualTicks.length > 0 ? (
+
+ {visualTicks.map((tick) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+ {showBounds ? (
+
+ {format(min)}
+ {format(max)}
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ gap: spacing[8],
+ },
+ disabled: {
+ opacity: 0.45,
+ },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing[8],
+ },
+ headerLabel: {
+ ...typography.overlineSm,
+ color: colors.textSecondary,
+ flexShrink: 1,
+ },
+ headerValue: {
+ ...typography.cardTitle,
+ color: colors.textPrimary,
+ },
+ railContainer: {
+ minHeight: sliderSizes.minTouchSize,
+ justifyContent: 'center',
+ },
+ track: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ },
+ thumbPosition: {
+ position: 'absolute',
+ left: -sliderSizes.thumbRadius,
+ },
+ ticksRow: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: sliderSizes.minTouchSize - sliderMetrics.trackHeight,
+ height: spacing[12],
+ },
+ tickMark: {
+ position: 'absolute',
+ width: spacing[2],
+ height: spacing[6],
+ borderRadius: spacing[2],
+ marginLeft: -spacing[2] / 2,
+ backgroundColor: colors.inputBorder,
+ },
+ boundsRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ boundsText: {
+ ...typography.metadataSm,
+ fontFamily: typography.overlineSm.fontFamily,
+ color: colors.textSecondary,
+ },
+});
+
+export default Slider;
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
new file mode 100644
index 0000000..c5a8480
--- /dev/null
+++ b/src/components/ui/index.ts
@@ -0,0 +1,8 @@
+export { Header } from './Header';
+export { RangeSlider } from './RangeSlider';
+export { Screen } from './Screen';
+export { Slider } from './Slider';
+
+export type { RangeSliderProps } from './RangeSlider';
+export type { SliderProps } from './Slider';
+
diff --git a/src/components/ui/sliderParts.tsx b/src/components/ui/sliderParts.tsx
new file mode 100644
index 0000000..06ab6a7
--- /dev/null
+++ b/src/components/ui/sliderParts.tsx
@@ -0,0 +1,80 @@
+import { LinearGradient } from 'expo-linear-gradient';
+import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
+import Animated from 'react-native-reanimated';
+
+import { colors, gradientDirection, gradients } from '@/theme/colors';
+import { borderRadius, sliderMetrics, sliderShadow } from '@/theme/spacing';
+
+export const sliderSizes = {
+ trackHeight: sliderMetrics.trackHeight,
+ thumbSize: sliderMetrics.thumbSize,
+ thumbRadius: sliderMetrics.thumbSize / 2,
+ thumbBorderWidth: sliderMetrics.thumbBorderWidth,
+ minTouchSize: sliderMetrics.minTouchSize,
+ trackRadius: borderRadius.chip,
+ thumbHitSlop: (sliderMetrics.minTouchSize - sliderMetrics.thumbSize) / 2,
+} as const;
+
+type SliderTrackProps = {
+ style?: StyleProp;
+ children?: React.ReactNode;
+};
+
+export function SliderTrack({ style, children }: SliderTrackProps) {
+ return {children};
+}
+
+type SliderFillProps = {
+ style?: StyleProp;
+};
+
+export function SliderFill({ style }: SliderFillProps) {
+ return (
+
+
+
+ );
+}
+
+type SliderThumbProps = {
+ style?: StyleProp;
+};
+
+export function SliderThumb({ style }: SliderThumbProps) {
+ return ;
+}
+
+const styles = StyleSheet.create({
+ track: {
+ height: sliderSizes.trackHeight,
+ borderRadius: sliderSizes.trackRadius,
+ backgroundColor: colors.surfaceElevated,
+ overflow: 'hidden',
+ },
+ fillMask: {
+ height: sliderSizes.trackHeight,
+ borderRadius: sliderSizes.trackRadius,
+ overflow: 'hidden',
+ },
+ fillGradient: {
+ flex: 1,
+ },
+ thumb: {
+ width: sliderSizes.thumbSize,
+ height: sliderSizes.thumbSize,
+ borderRadius: sliderSizes.thumbRadius,
+ borderWidth: sliderSizes.thumbBorderWidth,
+ borderColor: colors.primary,
+ backgroundColor: colors.onPrimary,
+ shadowColor: colors.primary,
+ shadowOffset: { width: 0, height: 0 },
+ shadowOpacity: sliderShadow.opacity,
+ shadowRadius: sliderShadow.radius,
+ elevation: sliderShadow.elevation,
+ },
+});
diff --git a/src/theme/spacing.ts b/src/theme/spacing.ts
index 71aabb5..39087b3 100644
--- a/src/theme/spacing.ts
+++ b/src/theme/spacing.ts
@@ -1,10 +1,15 @@
export const spacing = {
+ 2: 2,
4: 4,
+ 6: 6,
8: 8,
12: 12,
16: 16,
+ 20: 20,
24: 24,
+ 26: 26,
32: 32,
+ 44: 44,
} as const;
export const space = spacing;
@@ -15,4 +20,25 @@ export const borderRadius = {
chip: 9999,
} as const;
+export const sliderMetrics = {
+ trackHeight: spacing[6],
+ thumbSize: spacing[26],
+ thumbBorderWidth: 3,
+ minTouchSize: spacing[44],
+ closeThumbThresholdPx: spacing[24],
+} as const;
+
+export const sliderMotion = {
+ activeScale: 1.18,
+ springDamping: 20,
+ springStiffness: 240,
+ springMass: 0.4,
+} as const;
+
+export const sliderShadow = {
+ opacity: 0.55,
+ radius: 12,
+ elevation: 6,
+} as const;
+
export default spacing;