Skip to content
pixelfor Expo
Ctrl K

Interaction

Adaptive Slider

An expressive interaction rebuilt for touch

Try it on your phone

Adaptive Slider

Scan to open Adaptive Slider in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Watermelon UI

Our Expo component

React Native Web

Component 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.

Back to Watermelon UI