Interaction
Adaptive Slider
An expressive interaction rebuilt for touch
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
adaptive-slider.tsx
import React, { useEffect, useState } from "react"
import {
Platform,
Text,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
Easing,
LinearTransition,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import { LinearGradient } from "expo-linear-gradient"
export interface AdaptiveSliderProps {
value?: number
min?: number
max?: number
step?: number
defaultValue?: number
onChange?: (value: number) => void
onChangeEnd?: (value: number) => void
theme?: "light" | "dark" | "system"
compact?: boolean
width?: number
height?: number
radius?: number
colors?: Partial<{
surface: string
label: string
text: string
digits: string
track: string
dot: string
thumb: string
}>
gradients?: readonly [
readonly [string, string],
readonly [string, string],
readonly [string, string],
]
fontFamily?: string
style?: StyleProp<ViewStyle>
testID?: string
}
const SPRING = { stiffness: 300, damping: 30, mass: 1 }
const DEFAULT_GRADIENTS = [
["#feb101", "#fe7c09"],
["#fe55b74d", "#fe55b7"],
["#dab0fe", "#4946ff"],
] as const
export function AdaptiveSlider({
value,
min = 50,
max = 350,
step = 25,
defaultValue = 200,
onChange,
onChangeEnd,
theme = "system",
compact,
width,
height,
radius = 36,
colors: overrides,
gradients = DEFAULT_GRADIENTS,
fontFamily = Platform.OS === "web" ? "Arial" : undefined,
style,
testID,
}: AdaptiveSliderProps) {
const [internal, setInternal] = useState(defaultValue),
[trackWidth, setTrackWidth] = useState(0)
const calories = value ?? internal,
scheme = useColorScheme(),
window = useWindowDimensions(),
small = compact ?? window.width < 640,
dark = theme === "dark" || (theme === "system" && scheme === "dark")
const colors = {
surface: dark ? "#171717" : "#fefefe",
label: dark ? "#737373" : "#878787",
text: dark ? "#f5f5f5" : "#010101",
digits: "#8b8b8b",
track: dark ? "#262626" : "#f1f3f5",
dot: dark ? "#525252" : "#c4b9fa",
thumb: "#fff",
...overrides,
}
const fraction = (calories - min) / Math.max(1, max - min),
tier = fraction < 0.5 ? 0 : fraction < 0.7 ? 1 : 2
const position = useSharedValue(0),
chosen = useSharedValue(calories)
useEffect(() => {
chosen.set(calories)
position.set(
withSpring(
Math.max(0, Math.min(1, fraction)) * Math.max(0, trackWidth - 52),
SPRING
)
)
}, [calories, trackWidth, min, max])
function commit(next: number) {
setInternal(next)
onChange?.(next)
}
function update(x: number) {
"worklet"
const ratio = Math.max(
0,
Math.min(1, (x - 26) / Math.max(1, trackWidth - 52))
)
const next = Math.max(
min,
Math.min(
max,
min +
Math.round((ratio * (max - min)) / Math.max(step, 0.000001)) *
Math.max(step, 0.000001)
)
)
if (next !== chosen.get()) {
chosen.set(next)
if (value === undefined)
position.set(
withSpring(
((next - min) / Math.max(1, max - min)) *
Math.max(0, trackWidth - 52),
SPRING
)
)
scheduleOnRN(commit, next)
}
}
const gesture = Gesture.Pan()
.minDistance(0)
.onBegin((e) => {
update(e.x)
})
.onUpdate((e) => {
update(e.x)
})
.onEnd(() => {
if (onChangeEnd) scheduleOnRN(onChangeEnd, chosen.get())
})
const thumb = useAnimatedStyle(() => ({
transform: [{ translateX: position.get() }],
}))
const fill = useAnimatedStyle(() => ({ width: position.get() + 52 }))
function adjust(direction: number) {
const next = Math.max(min, Math.min(max, calories + direction * step))
commit(next)
onChangeEnd?.(next)
}
return (
<View
testID={testID}
style={[
{
alignSelf: "center",
width: width ?? (small ? 320 : 384),
height: height ?? window.height * 0.6,
padding: small ? 24 : 48,
borderRadius: radius,
backgroundColor: colors.surface,
alignItems: "center",
justifyContent: "center",
boxShadow: dark ? undefined : "0px 25px 50px -12px rgba(0,0,0,.05)",
},
style,
]}
>
<Text
style={{
fontFamily,
fontSize: small ? 20 : 24,
fontWeight: "700",
color: colors.label,
marginBottom: 8,
}}
>
Calories
</Text>
<View
style={{
flexDirection: "row",
alignItems: "baseline",
gap: 8,
marginBottom: 32,
}}
>
<View style={{ flexDirection: "row", overflow: "hidden" }}>
{String(calories)
.split("")
.map((char, index) => (
<Text
key={char + index}
style={{
fontFamily,
fontSize: small ? 48 : 60,
fontWeight: "800",
letterSpacing: -(small ? 48 : 60) * 0.025,
color: colors.digits,
}}
>
{char}
</Text>
))}
</View>
<Animated.Text
layout={LinearTransition.duration(450).easing(Easing.bezier(.4,0,.1,1))}
style={{
fontFamily,
fontSize: small ? 36 : 48,
fontWeight: "800",
color: colors.text,
}}
>
kCal
</Animated.Text>
</View>
<GestureDetector gesture={gesture}>
<View
onLayout={(e) => setTrackWidth(e.nativeEvent.layout.width)}
accessible
accessibilityRole="adjustable"
accessibilityLabel="Calories"
accessibilityValue={{
min,
max,
now: calories,
text: `${calories} kCal`,
}}
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
onAccessibilityAction={(e) =>
adjust(e.nativeEvent.actionName === "increment" ? 1 : -1)
}
style={{
width: "100%",
height: 52,
borderRadius: 26,
backgroundColor: colors.track,
overflow: "hidden",
}}
>
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
left: 0,
top: 0,
height: 52,
borderRadius: 26,
overflow: "hidden",
},
fill,
]}
>
<LinearGradient
colors={[gradients[tier][0], gradients[tier][1]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{ flex: 1 }}
/>
</Animated.View>
<View
pointerEvents="none"
style={{
position: "absolute",
left: small ? 16 : 32,
right: small ? 16 : 32,
top: 23,
flexDirection: "row",
justifyContent: "space-between",
}}
>
{Array.from({ length: 6 }, (_, i) => (
<View
key={i}
style={{
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: colors.dot,
opacity: 0.8,
}}
/>
))}
</View>
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
left: 0,
top: 0,
width: 52,
height: 52,
alignItems: "center",
justifyContent: "center",
},
thumb,
]}
>
<View
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.thumb,
boxShadow: "inset 0px 2px 4px rgba(0,0,0,.06)",
}}
/>
</Animated.View>
</View>
</GestureDetector>
</View>
)
}
export default AdaptiveSlider
Give the gestures and motion a run on a real Android phone.