Draggable track with haptic ticks and precise thumb.
components/ui/slider/slider.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { Slider } from "@/components/ui/slider/slider";
export function Example() {
return (
<Slider />
);
} import * as React from "react";
import { forwardRef, useCallback } from "react";
import { View, type ViewProps, type GestureResponderEvent, type LayoutChangeEvent, } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { cn, useHaptic, spring, useReducedMotion, useTheme } from "@lumioui/core";
import { Glass } from "../glass";
const THUMB = 22;
const TRACK_H = 4;
export interface SliderProps extends Omit<ViewProps, "className"> {
/** Controlled value. Omit + use defaultValue for uncontrolled. */
value?: number;
/** Uncontrolled initial value. Default min. */
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
className?: string;
/** Show tick marks for each step. Default false. */
showTicks?: boolean;
/** Liquid-glass track + thumb — a frosted lane with a glass disc. */
glass?: boolean;
}
/**
* Slider — iOS-style slider with smooth spring-animated thumb.
*
* Matches SwiftUI's `Slider` — drag or tap a track to set a stepped
* numeric value. Features:
* - Smooth spring-animated thumb (glide spring)
* - Thumb grows while dragged, bounces on release
* - Haptic feedback on each step crossing
* - Optional tick marks for stepped values
* - Fill track in primary color
*/
export const Slider = forwardRef<React.ElementRef<typeof View>, SliderProps>(
function Slider(
{ value: controlledValue, defaultValue, onValueChange, min = 0, max = 100, step = 1, disabled, className, showTicks = false, glass = false, ...props },
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const reducedMotion = useReducedMotion();
const [internal, setInternal] = React.useState(defaultValue ?? min);
// Resolve during render, never in a passive effect.
const value = controlledValue ?? internal;
const trackWidth = useSharedValue(0);
// Plain-state mirror of the track width — shared values read during
// render don't subscribe, so ticks need state to position on layout.
const [trackW, setTrackW] = React.useState(0);
const targetProgress = useSharedValue((value - min) / (max - min));
const thumbScale = useSharedValue(1);
const lastStep = React.useRef(value);
// While a finger is on the track the thumb must track 1:1 — a spring
// there feels like lag. Taps and programmatic changes get the glide.
const dragging = React.useRef(false);
// Smooth the thumb position with a glide spring. Reduced motion: snap —
// no travel, the fill width still lands.
React.useEffect(() => {
const p = (value - min) / (max - min);
if (reducedMotion || dragging.current) targetProgress.value = p;
else targetProgress.value = withSpring(p, spring.glide);
}, [value, min, max, targetProgress, reducedMotion]);
const updateFromTouch = useCallback(
(locationX: number) => {
if (trackWidth.value <= 0) return;
const ratio = Math.min(Math.max(locationX / trackWidth.value, 0), 1);
let next = min + ratio * (max - min);
if (step > 0) {
next = Math.round((next - min) / step) * step + min;
}
next = Math.min(Math.max(next, min), max);
if (next !== value) {
// Haptic on step crossing
if (Math.round(next / step) !== Math.round(lastStep.current / step)) {
haptics.selection();
lastStep.current = next;
}
if (controlledValue === undefined) setInternal(next);
onValueChange?.(next);
}
},
[value, controlledValue, min, max, step, onValueChange, haptics, trackWidth]
);
const onResponderGrant = (e: GestureResponderEvent) => {
if (disabled) return;
thumbScale.value = reducedMotion ? 1 : withSpring(1.2, spring.bouncy);
// Grant stays "not dragging" — a tap glides to the touch point;
// the first move below flips into 1:1 tracking.
updateFromTouch(e.nativeEvent.locationX);
};
const onResponderMove = (e: GestureResponderEvent) => {
if (disabled) return;
dragging.current = true;
updateFromTouch(e.nativeEvent.locationX);
};
const onResponderEnd = (e: GestureResponderEvent) => {
if (disabled) return;
// Bouncy land
thumbScale.value = reducedMotion ? 1 : withSpring(1, spring.bouncy);
updateFromTouch(e.nativeEvent.locationX);
// Clear after the final update so a last-step change still lands
// with a small settle spring instead of a hard stop.
dragging.current = false;
};
const onLayout = (e: LayoutChangeEvent) => {
trackWidth.value = e.nativeEvent.layout.width;
setTrackW(e.nativeEvent.layout.width);
};
const fillStyle = useAnimatedStyle(() => ({
width: targetProgress.value * trackWidth.value,
}));
const thumbStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: targetProgress.value * Math.max(0, trackWidth.value - THUMB) },
{ scale: thumbScale.value },
],
}));
// Compute tick positions
const ticks = React.useMemo(() => {
if (!showTicks || step <= 0) return [];
const count = Math.floor((max - min) / step) + 1;
if (count > 30) return []; // too many ticks
return Array.from({ length: count }, (_, i) => min + i * step);
}, [showTicks, min, max, step]);
// Screen-reader stepper: adjustable elements get VoiceOver/TalkBack actions.
const handleAccessibilityAction = (e: { nativeEvent: { actionName: string } }) => {
if (disabled) return;
const delta = e.nativeEvent.actionName === "decrement" ? -step : step;
const next = Math.min(max, Math.max(min, value + delta));
if (next !== value) {
haptics.selection();
if (controlledValue === undefined) setInternal(next);
onValueChange?.(next);
}
};
return (
<View
ref={ref}
className={cn("h-11 justify-center", disabled && "opacity-50", className)}
// The whole 44pt row is the touch surface — the 4pt visual track
// alone was nearly untappable.
onStartShouldSetResponder={() => !disabled}
onMoveShouldSetResponder={() => !disabled}
onResponderGrant={onResponderGrant}
onResponderMove={onResponderMove}
onResponderRelease={onResponderEnd}
onResponderTerminate={onResponderEnd}
accessibilityRole="adjustable"
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
onAccessibilityAction={handleAccessibilityAction}
accessibilityValue={{ min, max, now: value }}
accessibilityState={{ disabled }}
// accessibilityValue/accessibilityState aren't forwarded to the DOM
// on web — mirror them with explicit aria-* props.
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
aria-disabled={disabled ?? false}
{...props}
>
<View
onLayout={onLayout}
className={cn("w-full", !glass && "bg-muted")}
style={{ height: glass ? 24 : TRACK_H, borderRadius: (glass ? 24 : TRACK_H) / 2 }}
>
{glass ? (
<Glass
effect="clear"
className="absolute inset-0 rounded-full"
pointerEvents="none"
/>
) : null}
{/* Fill — fill/thumb colors ride animated style-props, so raw
theme colors (not classes) are required here. */}
<Animated.View
style={[
{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
borderRadius: (glass ? 24 : TRACK_H) / 2,
backgroundColor: colors.primary,
},
fillStyle,
]}
/>
{/* Tick marks */}
{ticks.map((tick, i) => {
const ratio = (tick - min) / (max - min);
return (
<View
key={i}
style={{
position: "absolute",
width: 2,
height: 2,
borderRadius: 1,
backgroundColor: colors.mutedForeground,
opacity: 0.4,
left: ratio * (trackW - 2) || 0,
top: (glass ? 24 : TRACK_H) / 2 - 1,
}}
/>
);
})}
{/* Thumb */}
<Animated.View
style={[
{
width: THUMB,
height: THUMB,
borderRadius: THUMB / 2,
position: "absolute",
top: glass ? (24 - THUMB) / 2 : -(THUMB - TRACK_H) / 2,
// Physical-thumb drop shadow — raw black reads correctly on
// both schemes (iOS idiom), so intentionally not a token.
// Glass mode relies on the material's own depth instead.
...(glass
? {}
: {
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
}),
},
thumbStyle,
]}
className={glass ? "overflow-hidden" : "bg-primary"}
>
{glass ? (
<Glass
effect="regular"
sheen={0.6}
bend={0.4}
className="absolute inset-0 rounded-full"
pointerEvents="none"
/>
) : null}
</Animated.View>
</View>
</View>
);
}
);
Slider.displayName = "Slider";
| value? | number | Controlled value. Omit + use defaultValue for uncontrolled. | |
| defaultValue? | number | Uncontrolled initial value. Default min. | |
| onValueChange? | (value: number) => void | ||
| min? | number | ||
| max? | number | ||
| step? | number | ||
| disabled? | boolean | ||
| className? | string | ||
| showTicks? | boolean | Show tick marks for each step. Default false. | |
| glass? | boolean | Liquid-glass track + thumb — a frosted lane with a glass disc. |
Draggable track with haptic ticks and precise thumb.
import { SliderGlass } from "@/components/ui/slider/variants/glass";
export function Example() {
return (
<SliderGlass />
);
} import * as React from "react";
import { View, Text } from "react-native";
import { Slider } from "../slider";
import { Ambient } from "../../glass/ambient";
/** SliderGlass — a glass lane + disc scrubber over music artwork. */
export function SliderGlass() {
const [v, setV] = React.useState(38);
return (
<View className="relative h-[420px] w-full overflow-hidden rounded-3xl">
<Ambient seed="slider" />
<View className="absolute inset-x-5 bottom-8 gap-1">
<Slider glass value={v} onValueChange={setV} min={0} max={100} />
<View className="flex-row justify-between">
<Text className="text-xs text-white/80">1:14</Text>
<Text className="text-xs text-white/80">3:42</Text>
</View>
</View>
<View className="absolute left-5 top-5">
<Text className="text-[10px] font-semibold uppercase tracking-widest text-white/90">
GLASS SCRUBBER
</Text>
</View>
</View>
);
}
Draggable track with haptic ticks and precise thumb.
import { SliderPrice } from "@/components/ui/slider/variants/price";
export function Example() {
return (
<SliderPrice />
);
} import * as React from "react";
import { View } from "react-native";
import { Slider } from "../slider";
import { Icon } from "../../icon/icon";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
const SPOTS = [
{ name: "Menya Lane", price: 24, rating: "4.7" },
{ name: "Ichiba Counter", price: 38, rating: "4.9" },
{ name: "Toraya Ramen", price: 52, rating: "4.5" },
];
/** SliderPrice — max-price filter driving a live results list. */
export function SliderPrice() {
const [maxPrice, setMaxPrice] = React.useState(80);
const visible = SPOTS.filter((s) => s.price <= maxPrice);
return (
<View className="w-full gap-3">
<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">Ramen near Kyoto</Text>
<Text variant="small">under ${maxPrice}</Text>
</View>
<Slider glass value={maxPrice} onValueChange={setMaxPrice} min={20} max={200} step={5} className="w-full" />
<View className="gap-1">
{visible.map((s) => (
<Glass interactive key={s.name} curvature="edge" className="flex-row items-center gap-2 rounded-lg px-3 py-2">
<Icon name="star" size="sm" color="warning" filled />
<Text variant="small" className="flex-1">{s.name}</Text>
<Text variant="muted" className="tabular-nums">${s.price} · {s.rating}</Text>
</Glass>
))}
{visible.length === 0 && (
<Text variant="muted" className="py-2 text-center">No spots under ${maxPrice} — raise the budget</Text>
)}
</View>
</Glass>
</View>
);
}
Draggable track with haptic ticks and precise thumb.
import { SliderTicks } from "@/components/ui/slider/variants/ticks";
export function Example() {
return (
<SliderTicks />
);
} import * as React from "react";
import { View } from "react-native";
import { Slider } from "../slider";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** SliderTicks — volume slider with tick marks, plus disabled state. */
export function SliderTicks() {
const [volume, setVolume] = React.useState(60);
return (
<View className="w-full gap-3">
<Glass interactive curvature="edge" className="w-full gap-4 rounded-2xl p-4">
<View className="gap-2">
<View className="flex-row items-baseline justify-between">
<Text variant="small" className="font-semibold">Ringtone volume</Text>
<Text variant="muted">{volume}%</Text>
</View>
<Slider glass value={volume} onValueChange={setVolume} min={0} max={100} step={5} showTicks className="w-full" />
</View>
<View className="gap-2 border-t border-border pt-3">
<View className="flex-row items-baseline justify-between">
<Text variant="small" className="font-semibold">Alarm volume</Text>
<Text variant="muted">Locked by focus mode</Text>
</View>
<Slider glass value={30} onValueChange={() => {}} disabled className="w-full" />
</View>
</Glass>
</View>
);
}