Slider
Inline range slider
A tiny slider with a handle that makes room for its labels
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
range-slider-inline.tsx
import { useCallback, useEffect, useMemo, useState } from "react"
import {
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
cancelAnimation,
ReduceMotion,
useAnimatedProps,
useAnimatedStyle,
useDerivedValue,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
type Stop = { value: number; x: number }
type NativeReadoutProps = TextInputProps & { text?: string }
const Readout = Animated.createAnimatedComponent(TextInput)
const GRAB = { stiffness: 500, damping: 14, mass: 0.7 }
const GLIDE = { stiffness: 700, damping: 50, mass: 0.5 }
function clamp(value: number, min: number, max: number) {
"worklet"
return Math.min(max, Math.max(min, value))
}
function mapStops(
stops: Stop[],
point: number,
from: keyof Stop,
to: keyof Stop
) {
"worklet"
if (point <= stops[0][from]) return stops[0][to]
for (let i = 1; i < stops.length; i++) {
if (point <= stops[i][from]) {
const lower = stops[i - 1]
const upper = stops[i]
return (
lower[to] +
((point - lower[from]) / (upper[from] - lower[from])) *
(upper[to] - lower[to])
)
}
}
return stops[stops.length - 1][to]
}
function closestStop(stops: Stop[], x: number) {
"worklet"
let nearest = stops[0]
for (let i = 1; i < stops.length; i++) {
if (Math.abs(stops[i].x - x) < Math.abs(nearest.x - x)) nearest = stops[i]
}
return nearest
}
function defaultFormat(value: number) {
"worklet"
return String(value)
}
export interface InlineSliderProps {
label: string
min?: number
max?: number
step?: number
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
format?: (value: number) => string
disabled?: boolean
showTicks?: boolean
accessibilityLabel?: string
formatValueText?: (value: number) => string
backgroundColor?: string
foregroundColor?: string
fillColor?: string
tickColor?: string
height?: number
radius?: number
textInset?: number
labelFontSize?: number
valueFontSize?: number
reduceMotion?: boolean
style?: StyleProp<ViewStyle>
testID?: string
}
export function InlineSlider({
label,
min = 0,
max: suppliedMax = 100,
step: suppliedStep = 1,
value,
defaultValue,
onValueChange,
format = defaultFormat,
disabled = false,
showTicks = true,
accessibilityLabel,
formatValueText,
backgroundColor = "#f4f4f5",
foregroundColor = "#18181b",
fillColor = "rgba(24,24,27,0.15)",
tickColor = "rgba(24,24,27,0.25)",
height = 40,
radius = 8,
textInset = 20,
labelFontSize = 14,
valueFontSize = 13,
reduceMotion,
style,
testID,
}: InlineSliderProps) {
const max = Math.max(min, suppliedMax)
const step =
suppliedStep > 0 && Number.isFinite(suppliedStep) ? suppliedStep : 1
const precision =
step.toFixed(6).replace(/0+$/, "").split(".")[1]?.length ?? 0
const trackHeight = Math.max(40, height)
const touchHeight = Math.max(48, trackHeight)
const [internal, setInternal] = useState(defaultValue ?? min)
const current = clamp(value !== undefined ? value : internal, min, max)
const [width, setWidth] = useState(292)
const [labelWidth, setLabelWidth] = useState(0)
const [readoutWidth, setReadoutWidth] = useState(24)
const startX = 8
const endX = Math.max(startX, width - 12)
const systemReducedMotion = useReducedMotion()
const reduce = reduceMotion ?? systemReducedMotion
const motion =
reduceMotion === undefined
? ReduceMotion.System
: reduceMotion
? ReduceMotion.Always
: ReduceMotion.Never
const stops = useMemo(() => {
const values: number[] = []
for (let index = 0; index < 10; index++) {
const point = min + (index / 9) * (max - min)
const snapped =
index === 9
? max
: clamp(
Number(
(min + Math.round((point - min) / step) * step).toFixed(6)
),
min,
max
)
if (!values.includes(snapped)) values.push(snapped)
}
return values.map((stop, index) => ({
value: stop,
x:
values.length === 1
? startX
: startX + (index / (values.length - 1)) * (endX - startX),
}))
}, [min, max, step, endX])
const restingX = mapStops(stops, current, "value", "x")
const handleX = useSharedValue(restingX)
const displayedValue = useSharedValue(current)
const grabScale = useSharedValue(1)
const dragging = useSharedValue(false)
const grabOffset = useSharedValue(2)
useEffect(() => {
if (!dragging.get()) {
handleX.set(withSpring(restingX, { ...GLIDE, reduceMotion: motion }))
displayedValue.set(current)
}
}, [restingX, current, motion, dragging, handleX, displayedValue])
const commit = useCallback(
(next: number) => {
if (value === undefined) setInternal(next)
onValueChange?.(next)
},
[value, onValueChange]
)
const pan = useMemo(
() =>
Gesture.Pan()
.enabled(!disabled && max > min)
.maxPointers(1)
.minDistance(0)
.onStart((event) => {
cancelAnimation(handleX)
const thumb = handleX.get()
grabOffset.set(
Math.abs(event.x - thumb - 2) <= 12 ? event.x - thumb : 2
)
dragging.set(true)
grabScale.set(
reduce ? 1 : withSpring(1.35, { ...GRAB, reduceMotion: motion })
)
})
.onUpdate((event) => {
const x = clamp(event.x - grabOffset.get(), startX, endX)
handleX.set(x)
const raw = mapStops(stops, x, "x", "value")
displayedValue.set(Number(raw.toFixed(precision)))
})
.onEnd((event, success) => {
if (!success) return
const stop = closestStop(
stops,
clamp(event.x - grabOffset.get(), startX, endX)
)
handleX.set(
withSpring(value === undefined ? stop.x : restingX, {
...GLIDE,
reduceMotion: motion,
})
)
displayedValue.set(value === undefined ? stop.value : current)
scheduleOnRN(commit, stop.value)
})
.onFinalize((_event, success) => {
dragging.set(false)
grabScale.set(withSpring(1, { ...GRAB, reduceMotion: motion }))
if (!success) {
handleX.set(
withSpring(restingX, { ...GLIDE, reduceMotion: motion })
)
displayedValue.set(current)
}
}),
[
disabled,
min,
max,
handleX,
grabOffset,
dragging,
grabScale,
motion,
startX,
endX,
stops,
displayedValue,
precision,
value,
restingX,
current,
commit,
reduce,
]
)
const split = useDerivedValue(() => {
const x = handleX.get()
const overlap = (start: number, end: number) =>
Math.max(0, Math.min(1, (x + 4 - start) / 6, (end - x) / 6))
return Math.max(
overlap(textInset, textInset + labelWidth),
overlap(width - textInset - readoutWidth, width - textInset)
)
})
const fillStyle = useAnimatedStyle(() => ({
transform: [
{
translateX:
(handleX.get() >= endX ? width - 2 : handleX.get() + 8) - width + 2,
},
],
}))
const handleStyle = useAnimatedStyle(() => ({
transform: [{ translateX: handleX.get() }, { scaleY: grabScale.get() }],
}))
const stemStyle = useAnimatedStyle(() => ({ opacity: 1 - split.get() }))
const topStyle = useAnimatedStyle(() => ({
transform: [{ translateY: reduce ? 0 : -split.get() }],
}))
const bottomStyle = useAnimatedStyle(() => ({
transform: [{ translateY: reduce ? 0 : split.get() }],
}))
const textProps = useAnimatedProps<NativeReadoutProps>(() => ({
text: format(displayedValue.get()),
}))
const measureText = useMemo(() => {
const candidates = stops.flatMap((stop) => [
format(stop.value),
format(Number(stop.value.toFixed(precision))),
])
if (precision > 0)
candidates.push(
format(Number((max - 10 ** -precision).toFixed(precision))),
format(Number((min + 10 ** -precision).toFixed(precision)))
)
return candidates.sort((a, b) => b.length - a.length)[0] ?? format(current)
}, [stops, precision, format, max, current])
const valueStyle = {
color: foregroundColor,
fontSize: valueFontSize,
fontWeight: "600" as const,
fontVariant: ["tabular-nums"] as "tabular-nums"[],
}
const visibleTicks = showTicks
? stops.filter(({ x }) => {
const hitsLabel = x + 2 >= textInset && x - 2 <= textInset + labelWidth
const hitsValue =
x + 2 >= width - textInset - readoutWidth &&
x - 2 <= width - textInset
return !hitsLabel && !hitsValue
})
: []
const adjust = (direction: "increment" | "decrement") => {
if (disabled || max === min) return
let next = current
if (direction === "increment")
next = stops.find((stop) => stop.value > current)?.value ?? max
else {
for (let i = stops.length - 1; i >= 0; i--) {
if (stops[i].value < current) {
next = stops[i].value
break
}
}
}
if (next !== current) commit(next)
}
return (
<GestureDetector gesture={pan}>
<View
testID={testID}
collapsable={false}
accessible
accessibilityRole="adjustable"
accessibilityLabel={accessibilityLabel ?? label}
accessibilityState={{ disabled: disabled || max === min }}
aria-disabled={disabled || max === min}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current}
aria-valuetext={(formatValueText ?? format)(current)}
accessibilityValue={{
min,
max,
now: current,
text: (formatValueText ?? format)(current),
}}
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
onAccessibilityAction={({ nativeEvent }) => {
if (
nativeEvent.actionName === "increment" ||
nativeEvent.actionName === "decrement"
)
adjust(nativeEvent.actionName)
}}
onLayout={({ nativeEvent }) => {
const next = nativeEvent.layout.width
if (next > 0)
setWidth((previous) =>
Math.abs(next - previous) < 0.5 ? previous : next
)
}}
style={[
styles.root,
{ minHeight: touchHeight, opacity: disabled ? 0.5 : 1 },
style,
]}
>
<View
pointerEvents="none"
accessibilityElementsHidden
aria-hidden
importantForAccessibility="no-hide-descendants"
style={[
styles.track,
{ height: trackHeight, borderRadius: radius, backgroundColor },
]}
>
<View style={[styles.fillClip, { borderRadius: radius }]}>
<Animated.View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: fillColor, borderRadius: radius },
fillStyle,
]}
/>
</View>
<Text
numberOfLines={1}
onLayout={({ nativeEvent }) =>
setLabelWidth(nativeEvent.layout.width)
}
style={[
styles.label,
{
left: textInset,
color: foregroundColor,
fontSize: labelFontSize,
},
]}
>
{label}
</Text>
<Text
numberOfLines={1}
accessible={false}
onLayout={({ nativeEvent }) =>
setReadoutWidth(Math.max(1, nativeEvent.layout.width))
}
style={[styles.measure, valueStyle, { right: textInset }]}
>
{measureText}
</Text>
<Readout
editable={false}
caretHidden
contextMenuHidden
accessible={false}
pointerEvents="none"
underlineColorAndroid="transparent"
defaultValue={format(current)}
animatedProps={textProps}
style={[
styles.readout,
valueStyle,
{ right: textInset, width: readoutWidth },
]}
/>
{visibleTicks.map(({ x }) => (
<View
key={x}
style={[styles.tick, { left: x, backgroundColor: tickColor }]}
/>
))}
<Animated.View
style={[
styles.handle,
{ top: (trackHeight - 24) / 2 },
handleStyle,
]}
>
<Animated.View
style={[
styles.stem,
{ backgroundColor: foregroundColor },
stemStyle,
]}
/>
<Animated.View
style={[
styles.cap,
{ top: 0, backgroundColor: foregroundColor },
topStyle,
]}
/>
<Animated.View
style={[
styles.cap,
{ bottom: 0, backgroundColor: foregroundColor },
bottomStyle,
]}
/>
</Animated.View>
</View>
</View>
</GestureDetector>
)
}
const styles = StyleSheet.create({
root: { width: "100%", justifyContent: "center", direction: "ltr" },
track: { overflow: "hidden", justifyContent: "center" },
fillClip: {
position: "absolute",
left: 2,
right: 2,
top: 0,
bottom: 0,
overflow: "hidden",
},
label: {
position: "absolute",
maxWidth: "40%",
fontWeight: "500",
lineHeight: 20,
},
measure: { position: "absolute", opacity: 0, maxWidth: "40%" },
readout: {
position: "absolute",
padding: 0,
margin: 0,
height: 24,
maxWidth: "40%",
textAlign: "right",
textAlignVertical: "center",
includeFontPadding: false,
},
tick: {
position: "absolute",
top: "50%",
marginTop: -2,
width: 4,
height: 4,
borderRadius: 2,
},
handle: { position: "absolute", left: 0, width: 4, height: 24 },
stem: { width: 4, height: 24, borderRadius: 2 },
cap: { position: "absolute", width: 4, height: 4, borderRadius: 2 },
})
Give the gestures and motion a run on a real Android phone.