Two-thumb range selector with spring-smoothed thumbs, haptic step ticks, optional tick marks, and reduced-motion support.
components/ui/range-slider/range-slider.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0expo-haptics@*Live preview — the real component, driven by the props below.
import { RangeSlider } from "@/components/ui/range-slider/range-slider";
export function Example() {
return (
<RangeSlider />
);
} import * as React from "react";
import { forwardRef, useCallback, useMemo, useState } from "react";
import { View, type ViewProps, type LayoutChangeEvent } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
runOnJS,
type SharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, useHaptic, spring, timing, useReducedMotion, useTheme } from "@lumioui/core";
/**
* RangeSlider — two-thumb range selector with gesture-handler-driven dragging.
* Drag either thumb or the track between them to set a [min, max] range.
* <RangeSlider min={0} max={100} step={5} value={[20, 80]} onValueChange={setRange} />
* Spring-smoothed thumbs, haptic ticks on step change, reduced-motion aware.
*/
const THUMB = 22;
const TRACK_H = 6;
/** Step tick dot — translateX resolves against the shared track width on the
* UI thread, so ticks stay pinned to their step without percent strings. */
function RangeTick({
pct,
trackWidth,
}: {
pct: number;
trackWidth: SharedValue<number>;
}) {
const style = useAnimatedStyle(() => ({
transform: [{ translateX: pct * trackWidth.value - 2 }],
}));
return (
<Animated.View
className="absolute top-1/2 -mt-0.5 h-1 w-1 rounded-full bg-foreground/25"
style={style}
/>
);
}
export interface RangeSliderProps extends Omit<ViewProps, "value"> {
/** Current [low, high] values (controlled). Omit for uncontrolled. */
value?: [number, number];
/** Initial [low, high] for uncontrolled use. Default [min, max]. */
defaultValue?: [number, number];
/** Called with the new [low, high] on every change. */
onValueChange?: (value: [number, number]) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
/** Render a tick dot at each step. */
showTicks?: boolean;
className?: string;
}
export const RangeSlider = forwardRef<React.ElementRef<typeof View>, RangeSliderProps>(
function RangeSlider(
{ value, defaultValue, onValueChange, min = 0, max = 100, step = 1, disabled, showTicks = true, className, ...props },
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const reduce = useReducedMotion();
// Controlled + uncontrolled — `current` resolves during render, never in
// a passive effect.
const [internal, setInternal] = useState<[number, number]>(
defaultValue ?? [min, max]
);
const current = value ?? internal;
// Track width as a shared value — the pan worklets and animated styles
// read it on the UI thread; a plain state would freeze the first layout
// inside the gesture worklet.
const trackWidth = useSharedValue(0);
// Gesture config as one shared value so the pan (created once) reads live
// min/max/step/reduced-motion inside worklets instead of being rebuilt
// on every render.
const cfg = useSharedValue({ min, max, step, reduce });
React.useEffect(() => {
cfg.value = { min, max, step, reduce };
}, [min, max, step, reduce, cfg]);
// Normalized positions [0, 1] for low and high thumbs.
const lowPct = useSharedValue((current[0] - min) / (max - min));
const highPct = useSharedValue((current[1] - min) / (max - min));
const lowScale = useSharedValue(1);
const highScale = useSharedValue(1);
/** Shadow bloom while a thumb is grabbed — 0 idle, 1 lifted. */
const lowGlow = useSharedValue(0);
const highGlow = useSharedValue(0);
// Which thumb is active: 0 = low, 1 = high, -1 = none
const activeThumb = useSharedValue(-1);
// Last snapped value under the finger — drives live step-tick haptics.
const lastTick = useSharedValue(0);
const emitChange = useCallback(
(next: [number, number]) => {
if (value === undefined) setInternal(next);
onValueChange?.(next);
},
[value, onValueChange]
);
React.useEffect(() => {
const low = (current[0] - min) / (max - min);
const high = (current[1] - min) / (max - min);
if (reduce) {
// Reduced motion: jump to the new positions — no glide, no overshoot.
lowPct.value = low;
highPct.value = high;
} else {
// spring.bouncy — the "bouncy thumb" token: settle lands with a
// whisper of overshoot after a drag commits.
lowPct.value = withSpring(low, spring.bouncy);
highPct.value = withSpring(high, spring.bouncy);
}
}, [current, min, max, lowPct, highPct, reduce]);
const lastLowStep = React.useRef(current[0]);
const lastHighStep = React.useRef(current[1]);
const snapToStep = useCallback(
(raw: number) => {
if (step > 0) {
return Math.round((raw - min) / step) * step + min;
}
return raw;
},
[min, step]
);
const updateFromPosition = useCallback(
(x: number, thumb: 0 | 1) => {
const tw = trackWidth.value;
if (tw <= 0) return;
const ratio = Math.min(Math.max(x / tw, 0), 1);
const raw = min + ratio * (max - min);
const snapped = Math.min(Math.max(snapToStep(raw), min), max);
if (thumb === 0) {
const newLow = Math.min(snapped, current[1] - (step > 0 ? step : 0.001));
if (newLow !== current[0]) {
emitChange([newLow, current[1]]);
if (Math.round(newLow) !== lastLowStep.current) {
haptics.selection();
lastLowStep.current = Math.round(newLow);
}
}
} else {
const newHigh = Math.max(snapped, current[0] + (step > 0 ? step : 0.001));
if (newHigh !== current[1]) {
emitChange([current[0], newHigh]);
if (Math.round(newHigh) !== lastHighStep.current) {
haptics.selection();
lastHighStep.current = Math.round(newHigh);
}
}
}
},
[current, min, max, step, emitChange, haptics, trackWidth, snapToStep]
);
// Latest-commit ref — the pan gesture is created once (stable deps only),
// so the worklet delegates to the freshest JS-side commit through a
// stable trampoline instead of re-creating the gesture per value change.
const updateRef = React.useRef(updateFromPosition);
updateRef.current = updateFromPosition;
const updateJS = useCallback(
(x: number, thumb: 0 | 1) => updateRef.current(x, thumb),
[]
);
const onLayout = (e: LayoutChangeEvent) => {
trackWidth.value = e.nativeEvent.layout.width;
};
// Pan gesture for dragging thumbs — created once. Everything mutable it
// touches is a shared value (trackWidth, cfg, thumb positions) and the
// JS commit goes through the stable updateJS trampoline, so the gesture
// is only rebuilt when `disabled` flips.
const pan = useMemo(() => {
if (disabled) return null;
return Gesture.Pan()
.activateAfterLongPress(0)
.onStart((e) => {
"worklet";
const tw = trackWidth.value;
if (tw <= 0) return;
const { min: mn, max: mx, step: st, reduce: rm } = cfg.value;
const gapPct = (st > 0 ? st : 0.001) / (mx - mn);
const x = e.x;
const lowX = lowPct.value * tw;
const highX = highPct.value * tw;
// Pick the closest thumb
const useLow = Math.abs(x - lowX) <= Math.abs(x - highX);
activeThumb.value = useLow ? 0 : 1;
// Seed the step tick with the SNAPPED value under the grabbed
// thumb so the first onUpdate comparison is apples-to-apples.
const pos = useLow ? lowPct.value : highPct.value;
const raw0 = mn + pos * (mx - mn);
lastTick.value = st > 0 ? Math.round((raw0 - mn) / st) * st + mn : raw0;
// Tap-to-position: a touch that lands clearly OFF the thumb glides
// it there; a touch within the thumb keeps the drag strictly 1:1
// so grabs never jump.
const thumbX = useLow ? lowX : highX;
if (Math.abs(x - thumbX) > THUMB) {
const ratio = Math.min(Math.max(x / tw, 0), 1);
const target = useLow
? Math.min(ratio, highPct.value - gapPct)
: Math.max(ratio, lowPct.value + gapPct);
if (useLow) {
lowPct.value = rm ? target : withSpring(target, spring.bouncy);
} else {
highPct.value = rm ? target : withSpring(target, spring.bouncy);
}
runOnJS(updateJS)(x, useLow ? 0 : 1);
}
if (rm) {
// Reduced motion: no press-grow on the grabbed thumb.
lowScale.value = 1;
highScale.value = 1;
} else if (useLow) {
lowScale.value = withSpring(1.25, spring.press);
lowGlow.value = withTiming(1, timing.fast);
} else {
highScale.value = withSpring(1.25, spring.press);
highGlow.value = withTiming(1, timing.fast);
}
runOnJS(haptics.light)();
})
.onUpdate((e) => {
"worklet";
const tw = trackWidth.value;
if (tw <= 0) return;
const { min: mn, max: mx, step: st } = cfg.value;
const x = e.x;
const ratio = Math.min(Math.max(x / tw, 0), 1);
// Clamp to the opposite thumb minus one step so the thumbs (and
// the fill width) can never cross during the gesture.
const gapPct = (st > 0 ? st : 0.001) / (mx - mn);
if (activeThumb.value === 0) {
lowPct.value = Math.max(0, Math.min(ratio, highPct.value - gapPct));
} else if (activeThumb.value === 1) {
highPct.value = Math.min(1, Math.max(ratio, lowPct.value + gapPct));
}
// Live step ticks — snap the clamped thumb position and buzz once per step crossed.
const pos = activeThumb.value === 0 ? lowPct.value : highPct.value;
const raw = mn + pos * (mx - mn);
const snapped = st > 0 ? Math.round((raw - mn) / st) * st + mn : raw;
if (snapped !== lastTick.value) {
lastTick.value = snapped;
runOnJS(haptics.selection)();
}
})
.onEnd((e) => {
"worklet";
// Snap to step and fire callback
const x = e.x;
if (activeThumb.value === 0) {
// Thumb release — spring.bouncy snaps the thumb back to full
// size with a whisper of overshoot on drop.
lowScale.value = cfg.value.reduce ? 1 : withSpring(1, spring.bouncy);
lowGlow.value = withTiming(0, timing.normal);
runOnJS(updateJS)(x, 0);
} else if (activeThumb.value === 1) {
highScale.value = cfg.value.reduce ? 1 : withSpring(1, spring.bouncy);
highGlow.value = withTiming(0, timing.normal);
runOnJS(updateJS)(x, 1);
}
activeThumb.value = -1;
});
}, [disabled, cfg, trackWidth, lowPct, highPct, activeThumb, lastTick, lowScale, highScale, lowGlow, highGlow, haptics, updateJS]);
// Numeric geometry — px width + translateX driven by the shared track
// width (percent-string styles can't be computed on the UI thread).
const fillStyle = useAnimatedStyle(() => ({
transform: [{ translateX: lowPct.value * trackWidth.value }],
width: Math.max(0, (highPct.value - lowPct.value) * trackWidth.value),
}));
const lowThumbStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: lowPct.value * Math.max(0, trackWidth.value - THUMB) },
{ scale: lowScale.value },
],
// Shadow blooms while grabbed — the thumb reads as lifted off the track.
shadowOpacity: interpolate(lowGlow.value, [0, 1], [0.15, 0.35]),
shadowRadius: interpolate(lowGlow.value, [0, 1], [2, 8]),
elevation: interpolate(lowGlow.value, [0, 1], [2, 8]),
}));
const highThumbStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: highPct.value * Math.max(0, trackWidth.value - THUMB) },
{ scale: highScale.value },
],
shadowOpacity: interpolate(highGlow.value, [0, 1], [0.15, 0.35]),
shadowRadius: interpolate(highGlow.value, [0, 1], [2, 8]),
elevation: interpolate(highGlow.value, [0, 1], [2, 8]),
}));
const steps = Math.floor(Number(((max - min) / step).toFixed(6)));
const ticks =
showTicks && steps > 0 && steps <= 50
? Array.from({ length: steps + 1 }, (_, i) => Number((min + i * step).toFixed(6)))
: [];
// Screen-reader thumb control — each thumb moves one step per action,
// clamped so the thumbs never cross.
const nudge = useCallback(
(thumb: 0 | 1, dir: 1 | -1) => {
if (disabled) return;
const delta = (step > 0 ? step : (max - min) / 100) * dir;
if (thumb === 0) {
const next = Math.min(Math.max(current[0] + delta, min), current[1]);
if (next !== current[0]) {
haptics.selection();
emitChange([next, current[1]]);
}
} else {
const next = Math.max(Math.min(current[1] + delta, max), current[0]);
if (next !== current[1]) {
haptics.selection();
emitChange([current[0], next]);
}
}
},
[disabled, step, min, max, current, emitChange, haptics]
);
const handleA11yAction = useCallback(
(event: { nativeEvent: { actionName: string } }) => {
switch (event.nativeEvent.actionName) {
case "increment":
nudge(1, 1);
break;
case "decrement":
nudge(0, -1);
break;
case "raise-minimum":
nudge(0, 1);
break;
case "lower-minimum":
nudge(0, -1);
break;
case "raise-maximum":
nudge(1, 1);
break;
case "lower-maximum":
nudge(1, -1);
break;
}
},
[nudge]
);
const track = (
<View
onLayout={onLayout}
className="h-1.5 w-full rounded-full bg-muted"
>
{/* Fill */}
<Animated.View
className="absolute left-0 top-0 h-1.5 rounded-full bg-primary"
style={fillStyle}
/>
{/* Ticks */}
{ticks.length > 0 ? (
<View pointerEvents="none" className="absolute inset-y-0 left-0 right-0">
{ticks.map((t) => (
<RangeTick
key={t}
pct={(t - min) / (max - min)}
trackWidth={trackWidth}
/>
))}
</View>
) : null}
{/* Low thumb */}
<Animated.View
style={[
{
width: THUMB,
height: THUMB,
borderRadius: THUMB / 2,
backgroundColor: colors.primary,
position: "absolute",
top: -(THUMB - TRACK_H) / 2,
// EXCEPTION: the palette has no shadow token — shadows stay
// black under both schemes (a light-scheme `foreground`
// would glow white in dark mode). Matches ui Slider.
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 2,
},
lowThumbStyle,
]}
/>
{/* High thumb */}
<Animated.View
style={[
{
width: THUMB,
height: THUMB,
borderRadius: THUMB / 2,
backgroundColor: colors.primary,
position: "absolute",
top: -(THUMB - TRACK_H) / 2,
// EXCEPTION: the palette has no shadow token — shadows stay
// black under both schemes. Matches ui Slider.
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 2,
},
highThumbStyle,
]}
/>
</View>
);
return (
<View
ref={ref}
className={cn("h-11 w-full justify-center", disabled && "opacity-50", className)}
accessibilityRole="adjustable"
accessibilityValue={{
min,
max,
// `now` is a single number — report the low bound; the full
// `${low} to ${high}` range is announced via `text` and the label.
now: current[0],
text: `${current[0]} to ${current[1]}`,
}}
accessibilityState={{ disabled }}
// RNW drops accessibilityValue/accessibilityState — mirror so the
// web slider role reports the range.
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current[0]}
aria-valuetext={`${current[0]} to ${current[1]}`}
aria-disabled={disabled || undefined}
accessibilityLabel={`Range slider, minimum ${current[0]}, maximum ${current[1]}`}
accessibilityHint="Adjusts the maximum. Actions also available for each thumb."
accessibilityActions={[
{ name: "increment", label: "Raise maximum" },
{ name: "decrement", label: "Lower minimum" },
{ name: "raise-minimum", label: "Raise minimum" },
{ name: "lower-minimum", label: "Lower minimum" },
{ name: "raise-maximum", label: "Raise maximum" },
{ name: "lower-maximum", label: "Lower maximum" },
]}
onAccessibilityAction={handleA11yAction}
{...props}
>
{pan ? (
<GestureDetector gesture={pan}>
{/* The pan zone owns the full 44pt row so each 22px thumb gets
a ≥44pt effective touch target (onStart picks the nearest
thumb, so the whole row is the grab area). */}
<View collapsable={false} className="h-11 w-full justify-center">
{track}
</View>
</GestureDetector>
) : (
track
)}
</View>
);
}
);
RangeSlider.displayName = "RangeSlider";
| value? | [number, number] | Current [low, high] values (controlled). Omit for uncontrolled. | |
| defaultValue? | [number, number] | Initial [low, high] for uncontrolled use. Default [min, max]. | |
| onValueChange? | (value: [number, number]) => void | Called with the new [low, high] on every change. | |
| min? | number | ||
| max? | number | ||
| step? | number | ||
| disabled? | boolean | ||
| showTicks? | boolean | Render a tick dot at each step. | |
| className? | string |
Two-thumb range selector with spring-smoothed thumbs, haptic step ticks, optional tick marks, and reduced-motion support.
import { RangeBudget } from "@/components/ui/range-slider/variants/budget";
export function Example() {
return (
<RangeBudget />
);
} import * as React from "react";
import { View } from "react-native";
import { RangeSlider } from "../range-slider";
import { Ambient, Glass, Text } from "@lumioui/ui";
/** RangeBudget — dual-thumb monthly budget picker staged in a budget card. */
export function RangeBudget() {
const [range, setRange] = React.useState<[number, number]>([400, 1200]);
const midpoint = Math.round((range[0] + range[1]) / 2);
return (
<View className="relative w-full overflow-hidden rounded-3xl">
<Ambient seed="range-slider-budget" />
<View className="w-full gap-3 p-4">
<Glass interactive curvature="edge" className="w-full gap-3 rounded-2xl p-4">
<View className="flex-row items-baseline justify-between">
<Text variant="small" className="font-semibold">Monthly dining budget</Text>
<Text variant="muted" className="text-xs">Lumo Wallet</Text>
</View>
<RangeSlider min={0} max={2000} step={50} value={range} onValueChange={setRange} />
<Glass interactive curvature="edge" className="flex-row items-center justify-between rounded-xl px-3 py-2.5">
<Text variant="muted">Target range</Text>
<Text variant="small" className="font-medium tabular-nums">${range[0]} – ${range[1]} / mo</Text>
</Glass>
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs">Midpoint ${midpoint} covers ~{Math.round(midpoint / 40)} ramen nights</Text>
</View>
</Glass>
</View>
</View>
);
}
Two-thumb range selector with spring-smoothed thumbs, haptic step ticks, optional tick marks, and reduced-motion support.
import { RangeStates } from "@/components/ui/range-slider/variants/states";
export function Example() {
return (
<RangeStates />
);
} import * as React from "react";
import { View } from "react-native";
import { RangeSlider } from "../range-slider";
import { Ambient, Glass, Text } from "@lumioui/ui";
/** RangeStates — tick-free and disabled treatments. */
export function RangeStates() {
return (
<View className="relative w-full overflow-hidden rounded-3xl">
<Ambient seed="range-slider-states" />
<View className="w-full gap-3 p-4">
<Glass interactive curvature="edge" className="w-full gap-4 rounded-2xl p-4">
<View className="gap-2">
<Text variant="small" className="font-semibold">No ticks</Text>
<RangeSlider min={0} max={100} step={1} value={[10, 90]} onValueChange={() => {}} showTicks={false} />
</View>
<View className="gap-2 border-t border-border pt-3">
<Text variant="small" className="font-semibold">Disabled</Text>
<RangeSlider min={0} max={100} step={10} value={[30, 70]} onValueChange={() => {}} disabled />
</View>
</Glass>
</View>
</View>
);
}